quickshell: segment bar groups, pin server glance

This commit is contained in:
rope 2026-07-28 22:10:47 +01:00
parent 4df4062806
commit 7bfd1e25c3

View file

@ -775,6 +775,20 @@ in
font.pixelSize: 16 font.pixelSize: 16
} }
// BarSep: hairline between widget groups in the bar column.
// Wrapped in a plain Item because a Column refuses to position
// children that set their own horizontalCenter anchor.
component BarSep: Item {
width: Theme.barWidth
height: 1
Rectangle {
anchors.centerIn: parent
width: 16
height: 1
color: Theme.base02
}
}
// GlanceStat: a bar-column stat clock-sized value over a // GlanceStat: a bar-column stat clock-sized value over a
// faded 9px caption (the server glance rows). // faded 9px caption (the server glance rows).
component GlanceStat: Column { component GlanceStat: Column {
@ -1621,9 +1635,11 @@ in
precision: SystemClock.Minutes precision: SystemClock.Minutes
} }
// Top cluster: logo, clock, workspaces. The bar is a column // Top cluster: logo, clock, workspaces the three groups
// now, so the two clusters anchor to opposite ends and the gap // that belong to "this machine, right now", split by hairlines.
// between them is the OSD slot. // The server glance is NOT here: it anchors above the bottom
// cluster instead, so adding a workspace grows this column
// downward into empty space rather than shoving the stats.
Column { Column {
id: barTop id: barTop
anchors.top: parent.top anchors.top: parent.top
@ -1663,6 +1679,8 @@ in
} }
} }
BarSep {}
// Clock HH over mm; "HH:mm" does not fit a 46px column. // Clock HH over mm; "HH:mm" does not fit a 46px column.
Item { Item {
id: clockText id: clockText
@ -1713,6 +1731,8 @@ in
} }
} }
BarSep {}
// Workspace dots: accent pill for the focused workspace, // Workspace dots: accent pill for the focused workspace,
// dim dots otherwise. Stacked vertically now, so the // dim dots otherwise. Stacked vertically now, so the
// focused pill grows along HEIGHT rather than width. // focused pill grows along HEIGHT rather than width.
@ -1753,197 +1773,210 @@ in
} }
} }
// Server monitor: one persistent SSH streaming `top -b` }
// from the mediaserver (2s ticks, parsed line by line
// no per-poll reconnects) plus a streaming ping to the
// router. Glance = CPU/RAM numbers + ping ms; click
// opens the btop-style dropdown.
Item {
id: srvWidget
width: Theme.barWidth
height: srvGlanceCol.height
property bool connected: false // Mid cluster: the server glance, pinned just above the
property real cpu: 0 // bottom cluster. Its own anchor (not a barTop child) is the
property real memUsed: 0 // GiB // whole point: workspace count then changes nothing here.
property real memTotal: 0 //
property real pingMs: -1 // Server monitor: one persistent SSH streaming `top -b`
property string uptime: "" // from the mediaserver (2s ticks, parsed line by line
property string load: "" // no per-poll reconnects) plus a streaming ping to the
property var procs: [] // router. Glance = CPU/RAM numbers + ping ms; click
property int tempC: -1 // opens the btop-style dropdown.
property real rxBps: 0 Item {
property real txBps: 0 id: srvWidget
width: Theme.barWidth
height: srvGlanceCol.height
anchors.horizontalCenter: barBgRect.horizontalCenter
anchors.bottom: barBottom.top
anchors.bottomMargin: 20
// Fades out while the OSD takes this slot.
opacity: osdItem.shown ? 0 : 1
Behavior on opacity {
NumberAnimation { duration: Theme.animContent }
}
function fmtRate(bps) { property bool connected: false
const b = bps * 8; property real cpu: 0
if (b >= 1e9) return (b / 1e9).toFixed(2) + " Gb/s"; property real memUsed: 0 // GiB
if (b >= 1e6) return (b / 1e6).toFixed(1) + " Mb/s"; property real memTotal: 0
if (b >= 1e3) return Math.round(b / 1e3) + " Kb/s"; property real pingMs: -1
return Math.round(b) + " b/s"; property string uptime: ""
property string load: ""
property var procs: []
property int tempC: -1
property real rxBps: 0
property real txBps: 0
function fmtRate(bps) {
const b = bps * 8;
if (b >= 1e9) return (b / 1e9).toFixed(2) + " Gb/s";
if (b >= 1e6) return (b / 1e6).toFixed(1) + " Mb/s";
if (b >= 1e3) return Math.round(b / 1e3) + " Kb/s";
return Math.round(b) + " b/s";
}
// Split variants for the bar glance: number and unit
// live on separate lines, so both stay narrow.
function rateVal(bps) {
const b = bps * 8;
if (b >= 1e9) return (b / 1e9).toFixed(1);
if (b >= 1e7) return Math.round(b / 1e6).toString();
if (b >= 1e6) return (b / 1e6).toFixed(1);
return Math.round(b / 1e3).toString();
}
function rateUnit(bps) {
const b = bps * 8;
if (b >= 1e9) return "gb/s";
if (b >= 1e6) return "mb/s";
return "kb/s";
}
property var _procsPending: []
property bool _collecting: false
function _parseLine(line) {
if (line.indexOf("@STAT ") === 0) {
const t = line.match(/temp=(\d+)/);
if (t) tempC = parseInt(t[1]);
const rx = line.match(/rxbps=(-?\d+)/);
if (rx) rxBps = Math.max(0, parseInt(rx[1]));
const tx = line.match(/txbps=(-?\d+)/);
if (tx) txBps = Math.max(0, parseInt(tx[1]));
return;
} }
if (line.indexOf("top - ") === 0) {
// Split variants for the bar glance: number and unit if (_collecting) procs = _procsPending;
// live on separate lines, so both stay narrow. _collecting = false;
function rateVal(bps) { const up = line.match(/up\s+(.*?),\s*\d+\s+users?,/);
const b = bps * 8; if (up) uptime = up[1].replace(/\s+/g, " ");
if (b >= 1e9) return (b / 1e9).toFixed(1); const la = line.match(/load average:\s*(.*)$/);
if (b >= 1e7) return Math.round(b / 1e6).toString(); if (la) load = la[1];
if (b >= 1e6) return (b / 1e6).toFixed(1); return;
return Math.round(b / 1e3).toString();
} }
function rateUnit(bps) { if (line.indexOf("%Cpu(s)") === 0) {
const b = bps * 8; const m = line.match(/([\d.]+)\s+id/);
if (b >= 1e9) return "gb/s"; if (m) { cpu = Math.max(0, 100 - parseFloat(m[1])); connected = true; }
if (b >= 1e6) return "mb/s"; return;
return "kb/s";
} }
const mem = line.match(/^(\S+) Mem\s*:\s*([\d.]+)\s+total,\s*[\d.]+\s+free,\s*([\d.]+)\s+used/);
property var _procsPending: [] if (mem) {
property bool _collecting: false const div = mem[1] === "KiB" ? 1048576 : mem[1] === "MiB" ? 1024 : 1;
memTotal = parseFloat(mem[2]) / div;
function _parseLine(line) { memUsed = parseFloat(mem[3]) / div;
if (line.indexOf("@STAT ") === 0) { return;
const t = line.match(/temp=(\d+)/); }
if (t) tempC = parseInt(t[1]); if (/^\s*PID\s/.test(line)) { _collecting = true; _procsPending = []; return; }
const rx = line.match(/rxbps=(-?\d+)/); if (_collecting) {
if (rx) rxBps = Math.max(0, parseInt(rx[1])); const f = line.trim().split(/\s+/);
const tx = line.match(/txbps=(-?\d+)/); if (f.length >= 12) {
if (tx) txBps = Math.max(0, parseInt(tx[1])); _procsPending.push({ pid: f[0], cpu: parseFloat(f[8]), mem: parseFloat(f[9]), name: f.slice(11).join(" ") });
return; // top sorts by %CPU; first 10 rows are the story
} if (_procsPending.length >= 10) { procs = _procsPending; _collecting = false; }
if (line.indexOf("top - ") === 0) { } else {
if (_collecting) procs = _procsPending; procs = _procsPending; _collecting = false;
_collecting = false;
const up = line.match(/up\s+(.*?),\s*\d+\s+users?,/);
if (up) uptime = up[1].replace(/\s+/g, " ");
const la = line.match(/load average:\s*(.*)$/);
if (la) load = la[1];
return;
}
if (line.indexOf("%Cpu(s)") === 0) {
const m = line.match(/([\d.]+)\s+id/);
if (m) { cpu = Math.max(0, 100 - parseFloat(m[1])); connected = true; }
return;
}
const mem = line.match(/^(\S+) Mem\s*:\s*([\d.]+)\s+total,\s*[\d.]+\s+free,\s*([\d.]+)\s+used/);
if (mem) {
const div = mem[1] === "KiB" ? 1048576 : mem[1] === "MiB" ? 1024 : 1;
memTotal = parseFloat(mem[2]) / div;
memUsed = parseFloat(mem[3]) / div;
return;
}
if (/^\s*PID\s/.test(line)) { _collecting = true; _procsPending = []; return; }
if (_collecting) {
const f = line.trim().split(/\s+/);
if (f.length >= 12) {
_procsPending.push({ pid: f[0], cpu: parseFloat(f[8]), mem: parseFloat(f[9]), name: f.slice(11).join(" ") });
// top sorts by %CPU; first 10 rows are the story
if (_procsPending.length >= 10) { procs = _procsPending; _collecting = false; }
} else {
procs = _procsPending; _collecting = false;
}
} }
} }
}
Process { Process {
id: srvTop id: srvTop
command: [Commands.ssh, "-o", "BatchMode=yes", "-o", "ConnectTimeout=3", command: [Commands.ssh, "-o", "BatchMode=yes", "-o", "ConnectTimeout=3",
"-o", "StrictHostKeyChecking=accept-new", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2",
"fred@10.0.0.1", "qs-stats"] "fred@10.0.0.1", "qs-stats"]
running: true running: true
stdout: SplitParser { stdout: SplitParser {
onRead: data => srvWidget._parseLine(data) onRead: data => srvWidget._parseLine(data)
}
onRunningChanged: if (!running) { srvWidget.connected = false; srvRestart.start(); }
} }
Timer { id: srvRestart; interval: 10000; onTriggered: srvTop.running = true } onRunningChanged: if (!running) { srvWidget.connected = false; srvRestart.start(); }
}
Timer { id: srvRestart; interval: 10000; onTriggered: srvTop.running = true }
// Ping is deliberately separate from the SSH stream: // Ping is deliberately separate from the SSH stream:
// ping up + ssh down = box alive but wedged (seen it). // ping up + ssh down = box alive but wedged (seen it).
Process { Process {
id: srvPing id: srvPing
command: [Commands.ping, "-n", "-i", "2", "10.0.0.1"] command: [Commands.ping, "-n", "-i", "2", "10.0.0.1"]
running: true running: true
stdout: SplitParser { stdout: SplitParser {
onRead: data => { onRead: data => {
const m = data.match(/time=([\d.]+)\s*ms/); const m = data.match(/time=([\d.]+)\s*ms/);
if (m) { srvWidget.pingMs = parseFloat(m[1]); pingStale.restart(); } if (m) { srvWidget.pingMs = parseFloat(m[1]); pingStale.restart(); }
}
}
onRunningChanged: if (!running) pingRestart.start()
}
Timer { id: pingRestart; interval: 10000; onTriggered: srvPing.running = true }
// No echo reply for 3 intervals -> show dashes, not a stale number.
Timer { id: pingStale; interval: 6000; onTriggered: srvWidget.pingMs = -1 }
function openSrvDropdown() {
bar.toggleDropdown(srvDropdown, function() {
let pos = srvWidget.mapToItem(bar.contentItem, 0, srvWidget.height / 2);
srvDropdown.dropdownY = pos.y;
});
}
// Stat stack, clock-sized values over faded captions.
Column {
id: srvGlanceCol
anchors.centerIn: parent
spacing: 5
GlanceStat {
value: srvWidget.connected ? Math.round(srvWidget.cpu) + "%" : "--"
caption: "cpu"
valueColor: srvWidget.connected ? Theme.base0D : Theme.base03
}
GlanceStat {
value: srvWidget.connected && srvWidget.memTotal > 0
? Math.round(100 * srvWidget.memUsed / srvWidget.memTotal) + "%" : "--"
caption: "ram"
valueColor: srvWidget.connected ? Theme.base0C : Theme.base03
}
GlanceStat {
value: srvWidget.connected && srvWidget.tempC >= 0 ? srvWidget.tempC + "°" : "--"
caption: "temp"
valueColor: !srvWidget.connected || srvWidget.tempC < 0 ? Theme.base03
: srvWidget.tempC >= 85 ? Theme.base08
: srvWidget.tempC >= 70 ? Theme.base0A : Theme.base05
}
GlanceStat {
value: srvWidget.connected ? srvWidget.rateVal(srvWidget.rxBps) : "--"
caption: " " + srvWidget.rateUnit(srvWidget.rxBps)
valueColor: srvWidget.connected ? Theme.base0B : Theme.base03
}
GlanceStat {
value: srvWidget.connected ? srvWidget.rateVal(srvWidget.txBps) : "--"
caption: " " + srvWidget.rateUnit(srvWidget.txBps)
valueColor: srvWidget.connected ? Theme.base09 : Theme.base03
}
GlanceStat {
value: srvWidget.pingMs < 0 ? "--"
: srvWidget.pingMs < 10 ? srvWidget.pingMs.toFixed(1)
: Math.round(srvWidget.pingMs).toString()
caption: "ms"
valueColor: srvWidget.pingMs < 0 ? Theme.base03 : Theme.base04
} }
} }
onRunningChanged: if (!running) pingRestart.start()
}
Timer { id: pingRestart; interval: 10000; onTriggered: srvPing.running = true }
// No echo reply for 3 intervals -> show dashes, not a stale number.
Timer { id: pingStale; interval: 6000; onTriggered: srvWidget.pingMs = -1 }
MouseArea { function openSrvDropdown() {
anchors.fill: parent bar.toggleDropdown(srvDropdown, function() {
hoverEnabled: true let pos = srvWidget.mapToItem(bar.contentItem, 0, srvWidget.height / 2);
onClicked: srvWidget.openSrvDropdown() srvDropdown.dropdownY = pos.y;
onEntered: { });
if (bar.activeDropdown) { }
if (bar.activeDropdown !== srvDropdown) srvWidget.openSrvDropdown();
} // Stat stack, clock-sized values over faded captions.
Column {
id: srvGlanceCol
anchors.centerIn: parent
spacing: 5
GlanceStat {
value: srvWidget.connected ? Math.round(srvWidget.cpu) + "%" : "--"
caption: "cpu"
valueColor: srvWidget.connected ? Theme.base0D : Theme.base03
}
GlanceStat {
value: srvWidget.connected && srvWidget.memTotal > 0
? Math.round(100 * srvWidget.memUsed / srvWidget.memTotal) + "%" : "--"
caption: "ram"
valueColor: srvWidget.connected ? Theme.base0C : Theme.base03
}
GlanceStat {
value: srvWidget.connected && srvWidget.tempC >= 0 ? srvWidget.tempC + "°" : "--"
caption: "temp"
valueColor: !srvWidget.connected || srvWidget.tempC < 0 ? Theme.base03
: srvWidget.tempC >= 85 ? Theme.base08
: srvWidget.tempC >= 70 ? Theme.base0A : Theme.base05
}
GlanceStat {
value: srvWidget.connected ? srvWidget.rateVal(srvWidget.rxBps) : "--"
caption: " " + srvWidget.rateUnit(srvWidget.rxBps)
valueColor: srvWidget.connected ? Theme.base0B : Theme.base03
}
GlanceStat {
value: srvWidget.connected ? srvWidget.rateVal(srvWidget.txBps) : "--"
caption: " " + srvWidget.rateUnit(srvWidget.txBps)
valueColor: srvWidget.connected ? Theme.base09 : Theme.base03
}
GlanceStat {
value: srvWidget.pingMs < 0 ? "--"
: srvWidget.pingMs < 10 ? srvWidget.pingMs.toFixed(1)
: Math.round(srvWidget.pingMs).toString()
caption: "ms"
valueColor: srvWidget.pingMs < 0 ? Theme.base03 : Theme.base04
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: srvWidget.openSrvDropdown()
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== srvDropdown) srvWidget.openSrvDropdown();
} }
} }
} }
} }
// Bottom cluster: status icons, tray, power button. Anchored // Bottom cluster: status icons, tray, power button. Anchored
// to the bottom edge so the gap above it between here and the // to the bottom edge; the server glance sits just above it and
// workspace dots stays free as the OSD slot. // the OSD borrows that same slot while it is on screen.
Column { Column {
id: barBottom id: barBottom
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
@ -2294,7 +2327,7 @@ in
// Group separator: status icons / tray / power read // Group separator: status icons / tray / power read
// as three clusters instead of one clump. // as three clusters instead of one clump.
Item { width: Theme.barWidth; height: 10 } BarSep {}
// Tray icons stacked, like everything else in the column // Tray icons stacked, like everything else in the column
Column { Column {
@ -2382,7 +2415,7 @@ in
// Group separator: status icons / tray / power read // Group separator: status icons / tray / power read
// as three clusters instead of one clump. // as three clusters instead of one clump.
Item { width: Theme.barWidth; height: 10 } BarSep {}
// Power menu opener bottom-left corner. Opens the same // Power menu opener bottom-left corner. Opens the same
// session menu as Super+L, which now unfolds from here. // session menu as Super+L, which now unfolds from here.
@ -4168,10 +4201,12 @@ in
property real value: 0 property real value: 0
property color fill: Theme.base0D property color fill: Theme.base0D
// Takes over the server glance's slot: fixed position, no
// dependence on how tall the workspace column has grown.
width: Theme.barWidth width: Theme.barWidth
height: 170
anchors.horizontalCenter: barBgRect.horizontalCenter anchors.horizontalCenter: barBgRect.horizontalCenter
anchors.top: barTop.bottom anchors.verticalCenter: srvWidget.verticalCenter
anchors.bottom: barBottom.top
opacity: shown ? 1 : 0 opacity: shown ? 1 : 0
visible: opacity > 0.01 visible: opacity > 0.01