aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules')
-rw-r--r--desktop/modules/companion/ArtistsModule.qml67
-rw-r--r--desktop/modules/companion/CompanionModule.qml2
-rw-r--r--desktop/modules/companion/PhrasesModule.qml64
-rw-r--r--desktop/modules/companion/PhrasesPage.qml195
-rw-r--r--desktop/modules/perf/Perf.js19
-rw-r--r--desktop/modules/perf/PerfPanel.qml93
-rw-r--r--desktop/modules/perf/README.md7
-rw-r--r--desktop/modules/perf/test_perf.js17
8 files changed, 462 insertions, 2 deletions
diff --git a/desktop/modules/companion/ArtistsModule.qml b/desktop/modules/companion/ArtistsModule.qml
new file mode 100644
index 0000000..a0d23f4
--- /dev/null
+++ b/desktop/modules/companion/ArtistsModule.qml
@@ -0,0 +1,67 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+import "../../companion/Lines.js" as Lines
+
+// The lines the companion says when a track by an artist she knows starts,
+// matched as a case-insensitive substring of the player's artist. Same
+// draft, Save and Reset as PhrasesModule, on Prefs.companionArtists and
+// Lines.artists; the page's cls field holds the artist.
+Module {
+ id: am
+ name: "artists"
+ label: "Companion artists"
+ // nf-fa-music, present in Inconsolata Nerd Font.
+ icon: "\uf001"
+ settings: true
+ group: "Companion"
+ keywords: ["companion", "artist", "music", "song", "track", "line", "say", "2b"]
+ searchItems: am.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i }))
+
+ property var draft: null
+ property bool resetting: false
+ readonly property var shown: am.draft ?? Lines.appList(Prefs.companionArtists, Lines.artists)
+ readonly property bool valid: am.shown.every(a => String(a.cls).trim() !== ""
+ && (a.lines || []).some(l => String(l).trim() !== ""))
+ dirty: am.draft !== null
+
+ function edit(list) { am.draft = list; am.resetting = false; }
+ function reset() { am.draft = Lines.appList(null, Lines.artists); am.resetting = true; }
+ function save() {
+ if (am.resetting) Prefs.resetCompanionArtists();
+ else Prefs.setCompanionArtists(Lines.toMap(am.draft));
+ am.discard();
+ }
+ function discard() { am.draft = null; am.resetting = false; }
+
+ page: Component {
+ Page {
+ title: "Companion artists"
+ actions: Component {
+ SaveBar { dirty: am.dirty; valid: am.valid; note: "every artist needs a name and a phrase"
+ onSave: am.save(); onDiscard: am.discard() }
+ }
+ PhrasesPage {
+ width: parent.width
+ owner: am
+ intro: "When a track by an artist she knows starts or changes, she says one of their lines at "
+ + "random, under her 30 s line cooldown. The name matches any part of the player's artist, "
+ + "ignoring case, so \"daft punk\" also catches \"Daft Punk, Pharrell Williams\". "
+ + "Save applies at once."
+ keyHint: "artist, e.g. linkin park"
+ addText: "Add artist"
+ saved: Prefs.companionArtists
+ }
+ }
+ }
+}
diff --git a/desktop/modules/companion/CompanionModule.qml b/desktop/modules/companion/CompanionModule.qml
index 6ed36ec..8d6a68d 100644
--- a/desktop/modules/companion/CompanionModule.qml
+++ b/desktop/modules/companion/CompanionModule.qml
@@ -23,7 +23,7 @@ Module {
// nf-md-account_heart, present in Inconsolata Nerd Font.
icon: "\u{F0899}"
settings: true
- group: "Desktop"
+ group: "Companion"
keywords: ["companion", "2b", "mascot", "character", "show", "hide", "wallpaper", "blush", "leotard", "timeout"]
page: Component {
diff --git a/desktop/modules/companion/PhrasesModule.qml b/desktop/modules/companion/PhrasesModule.qml
new file mode 100644
index 0000000..7e4895a
--- /dev/null
+++ b/desktop/modules/companion/PhrasesModule.qml
@@ -0,0 +1,64 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+import "../../companion/Lines.js" as Lines
+
+// The lines the companion says when focus moves to a program she
+// recognizes, by its lowercased window class. They live in Prefs and she
+// reads them live, so a save needs no restart. The page edits a draft here;
+// Save hands it to Prefs, Reset drops the saved set so Lines.js' defaults
+// show again.
+Module {
+ id: pm
+ name: "phrases"
+ label: "Companion phrases"
+ // nf-fa-comment, present in Inconsolata Nerd Font.
+ icon: "\uf075"
+ settings: true
+ group: "Companion"
+ keywords: ["companion", "phrase", "line", "say", "speak", "app", "program",
+ "window", "class", "firefox", "2b"]
+ searchItems: pm.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i }))
+
+ // null while there are no edits: the page then shows the saved set, or
+ // Lines.js' defaults when nothing is saved.
+ property var draft: null
+ // Reset was pressed: Save drops the saved set rather than saving a copy
+ // of the defaults, so later default changes still arrive.
+ property bool resetting: false
+ readonly property var shown: pm.draft ?? Lines.appList(Prefs.companionApps)
+ // Every app needs a class and at least one non-empty phrase; Save waits.
+ readonly property bool valid: pm.shown.every(a => String(a.cls).trim() !== ""
+ && (a.lines || []).some(l => String(l).trim() !== ""))
+ dirty: pm.draft !== null
+
+ function edit(list) { pm.draft = list; pm.resetting = false; }
+ function reset() { pm.draft = Lines.appList(null); pm.resetting = true; }
+ function save() {
+ if (pm.resetting) Prefs.resetCompanionApps();
+ else Prefs.setCompanionApps(Lines.toMap(pm.draft));
+ pm.discard();
+ }
+ function discard() { pm.draft = null; pm.resetting = false; }
+
+ page: Component {
+ Page {
+ title: "Companion phrases"
+ actions: Component {
+ SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every app needs a class and a phrase"
+ onSave: pm.save(); onDiscard: pm.discard() }
+ }
+ PhrasesPage { width: parent.width; owner: pm }
+ }
+ }
+}
diff --git a/desktop/modules/companion/PhrasesPage.qml b/desktop/modules/companion/PhrasesPage.qml
new file mode 100644
index 0000000..1971171
--- /dev/null
+++ b/desktop/modules/companion/PhrasesPage.qml
@@ -0,0 +1,195 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// One card per recognized program: its window class and the lines she says
+// when focus moves to it. Every change edits the module's draft; the
+// header's Save hands the set to Prefs. Reset fills the draft with the
+// built-in set from Lines.js.
+Column {
+ id: page
+ spacing: 10
+
+ // The module, holding the draft. Not named pm, its id (it would bind to
+ // itself, AGENTS.md).
+ required property QtObject owner
+ // What the cards are keyed by; the defaults are the app set's, so
+ // PhrasesModule passes nothing.
+ property string intro: "When focus moves to a program she recognizes, she says one of its lines at random, a third "
+ + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, "
+ + "lowercased (hyprctl activewindow shows it). Save applies at once."
+ property string keyHint: "window class, e.g. firefox"
+ property string addText: "Add app"
+ // The saved set, for Reset's enabled state; null means the defaults.
+ property var saved: Prefs.companionApps
+ readonly property var list: owner.shown
+ // The app card awaiting a second click on Remove.
+ property int confirming: -1
+
+ // Saves a copy of the list with app i replaced by fn(app), or removed
+ // when fn returns null.
+ function edit(i, fn) {
+ const out = [];
+ for (let j = 0; j < page.list.length; j++) {
+ const a = page.list[j];
+ const c = { cls: a.cls, lines: a.lines.slice() };
+ if (j !== i) { out.push(c); continue; }
+ const r = fn(c);
+ if (r) out.push(r);
+ }
+ page.confirming = -1;
+ page.owner.edit(out);
+ }
+
+ function addApp() { page.owner.edit(page.list.concat([{ cls: "", lines: [""] }])); }
+ function removeApp(i) { page.edit(i, () => null); }
+ function setCls(i, t) { page.edit(i, a => Object.assign(a, { cls: t })); }
+ function addLine(i) { page.edit(i, a => Object.assign(a, { lines: a.lines.concat([""]) })); }
+ function setLine(i, j, t) {
+ page.edit(i, a => {
+ const ls = a.lines.slice();
+ ls[j] = t;
+ return Object.assign(a, { lines: ls });
+ });
+ }
+ function removeLine(i, j) { page.edit(i, a => Object.assign(a, { lines: a.lines.filter((_, k) => k !== j) })); }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ // Reports every keystroke into the draft. Not bound to value, which
+ // would reset the cursor; it follows the value only when the draft moves
+ // under it (Discard, Reset). Empty is outlined, since Save refuses it.
+ component Field: Rectangle {
+ id: field
+ property string value: ""
+ property string placeholder: ""
+ signal edited(string text)
+ height: 32
+ radius: 8
+ color: Qt.alpha(Theme.base, 0.6)
+ border.width: ti.text.trim() === "" ? 1 : 0
+ border.color: Theme.red
+ onValueChanged: if (ti.text !== field.value) ti.text = field.value
+ TextInput {
+ activeFocusOnTab: true
+ id: ti
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ Component.onCompleted: text = field.value
+ onTextEdited: field.edited(text)
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: field.placeholder
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: page.intro
+ }
+
+ Repeater {
+ // A count, not the list: a new list on every keystroke would rebuild
+ // every card and drop the focus of the field being typed in.
+ model: page.list.length
+
+ delegate: Rectangle {
+ id: card
+ required property int index
+ readonly property var modelData: page.list[index] ?? { cls: "", lines: [] }
+ objectName: "app:" + index
+ width: page.width
+ height: body.implicitHeight + 20
+ radius: 10
+ color: Qt.alpha(Theme.surface, 0.45)
+
+ Column {
+ id: body
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 }
+ spacing: 8
+
+ Item {
+ width: parent.width
+ height: 32
+ Field {
+ anchors { left: parent.left; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: card.modelData.cls
+ placeholder: page.keyHint
+ onEdited: t => page.setCls(card.index, t)
+ }
+ Button {
+ id: del
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === card.index ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === card.index) page.removeApp(card.index);
+ else page.confirming = card.index;
+ }
+ }
+ }
+
+ Repeater {
+ model: card.modelData.lines.length
+ delegate: Row {
+ id: phraseRow
+ required property int index
+ width: body.width
+ spacing: 6
+ Field {
+ width: phraseRow.width - removeBtn.width - phraseRow.spacing
+ value: card.modelData.lines[phraseRow.index] ?? ""
+ placeholder: "a line she says"
+ onEdited: t => page.setLine(card.index, phraseRow.index, t)
+ }
+ Button {
+ id: removeBtn
+ // nf-fa-xmark, present in Inconsolata Nerd Font.
+ icon: "\uf00d"
+ danger: true
+ onClicked: page.removeLine(card.index, phraseRow.index)
+ }
+ }
+ }
+
+ Button {
+ text: "Add phrase"
+ onClicked: page.addLine(card.index)
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button { text: page.addText; onClicked: page.addApp() }
+ Button {
+ text: "Reset to defaults"
+ danger: true
+ enabled: !page.owner.resetting && (page.saved !== null || page.owner.dirty)
+ onClicked: page.owner.reset()
+ }
+ }
+}
diff --git a/desktop/modules/perf/Perf.js b/desktop/modules/perf/Perf.js
index d84c694..8294e94 100644
--- a/desktop/modules/perf/Perf.js
+++ b/desktop/modules/perf/Perf.js
@@ -188,6 +188,25 @@ function push(list, v, n) {
return out.length > n ? out.slice(out.length - n) : out;
}
+// The process rows of one `top -b` frame, run under LC_ALL=C: [{pid, name,
+// cpu, mem}], cpu in percent of one core as top shows it, mem the resident
+// size in bytes. RES is KiB unless top scaled it to a suffixed unit (1.0g),
+// and COMMAND is the last column and may hold spaces ("Isolated Web Co").
+function procs(text) {
+ const out = [];
+ let rows = false;
+ for (const line of text.split("\n")) {
+ if (/^\s*PID\s+USER\b/.test(line)) { rows = true; continue; }
+ if (!rows) continue;
+ const f = line.trim().split(/\s+/);
+ const res = /^([\d.]+)([kmgtp]?)$/.exec(f[5] || "");
+ if (f.length < 12 || !res) continue;
+ const scale = 1024 ** (1 + Math.max(0, "kmgtp".indexOf(res[2] || "k")));
+ out.push({ pid: Number(f[0]), name: f.slice(11).join(" "), cpu: Number(f[8]), mem: Number(res[1]) * scale });
+ }
+ return out;
+}
+
function bytes(n) {
if (n === null || n === undefined) return "--";
const u = ["B", "K", "M", "G", "T"];
diff --git a/desktop/modules/perf/PerfPanel.qml b/desktop/modules/perf/PerfPanel.qml
index aa2e49f..ff9f74b 100644
--- a/desktop/modules/perf/PerfPanel.qml
+++ b/desktop/modules/perf/PerfPanel.qml
@@ -131,6 +131,39 @@ Column {
// --- CPU ----------------------------------------------------------------
+ // The five heaviest processes, from one `top -b` streaming a frame every
+ // 2s for as long as the page is open; it dies with the panel. top marks
+ // no frame's end but writes each in one burst, so a frame is published
+ // once its output goes quiet. The first is skipped, since top computes
+ // it over each process's whole life rather than the last interval, so
+ // rows load about 2s after the page opens.
+ property var procs: []
+ property string procBy: "cpu"
+ property string frame: ""
+ property int frames: 0
+
+ Process {
+ running: true
+ command: ["env", "LC_ALL=C", "top", "-b", "-d2", "-w512"]
+ stdout: SplitParser {
+ onRead: line => {
+ // Every header after the first reaches here with the blank
+ // line before it still attached, as "\ntop - ...".
+ if (/^\s*top - /.test(line)) {
+ panel.frames++;
+ panel.frame = "";
+ }
+ panel.frame += line + "\n";
+ frameEnd.restart();
+ }
+ }
+ }
+ Timer {
+ id: frameEnd
+ interval: 150
+ onTriggered: if (panel.frames >= 2) panel.procs = P.procs(panel.frame)
+ }
+
Card {
title: "CPU"
headline: Perf.load === null ? "--" : Math.round(Perf.load) + "%"
@@ -174,6 +207,66 @@ Column {
frac: Perf.mem ? Perf.mem.used / Perf.mem.total : 0
fill: panel.tone(panel.pct(Perf.mem ? Perf.mem.used : null, Perf.mem ? Perf.mem.total : 0), 25, 75)
}
+
+ // Sorted by CPU or RAM, the active one lit; switching re-sorts the
+ // frame already held rather than waiting for the next.
+ Item {
+ width: parent.width
+ height: byRow.implicitHeight
+ Label { text: "Processes"; color: Theme.subtext }
+ Row {
+ id: byRow
+ anchors.right: parent.right
+ spacing: 10
+ Repeater {
+ model: [{ key: "cpu", text: "CPU" }, { key: "mem", text: "RAM" }]
+ delegate: Label {
+ required property var modelData
+ text: modelData.text
+ font.bold: panel.procBy === modelData.key
+ color: panel.procBy === modelData.key ? Theme.text : Theme.overlay
+ MouseArea {
+ anchors.fill: parent
+ anchors.margins: -4
+ cursorShape: Qt.PointingHandCursor
+ onClicked: panel.procBy = modelData.key
+ }
+ }
+ }
+ }
+ }
+ Repeater {
+ model: panel.procs.slice().sort((a, b) => b[panel.procBy] - a[panel.procBy]).slice(0, 5)
+ // Two right-aligned columns of fixed width, so the numbers line
+ // up down the table whatever their length.
+ delegate: Item {
+ required property var modelData
+ width: parent.width
+ implicitHeight: nameText.implicitHeight
+ Label {
+ id: nameText
+ anchors { left: parent.left; right: cpuText.left; rightMargin: 8 }
+ text: modelData.name
+ elide: Text.ElideRight
+ }
+ Label {
+ id: cpuText
+ anchors.right: memText.left
+ width: panel.fs * 4
+ horizontalAlignment: Text.AlignRight
+ text: modelData.cpu.toFixed(1) + "%"
+ font.features: { "tnum": 1 }
+ }
+ Label {
+ id: memText
+ anchors.right: parent.right
+ width: panel.fs * 4.5
+ horizontalAlignment: Text.AlignRight
+ text: P.bytes(modelData.mem)
+ font.features: { "tnum": 1 }
+ }
+ }
+ }
}
// --- temperatures -------------------------------------------------------
diff --git a/desktop/modules/perf/README.md b/desktop/modules/perf/README.md
index 236f5b4..8282a50 100644
--- a/desktop/modules/perf/README.md
+++ b/desktop/modules/perf/README.md
@@ -31,6 +31,13 @@ VRAM (a scan of every fdinfo, ~14ms every 2s) and the fan, so the page opens
on the last minute of network history instead of an empty graph. The mount list, `br0`, the GPU name and the 12 GiB VRAM
size are constants in `Perf.qml`.
+The CPU card ends with the five heaviest processes, by CPU or by RAM (a
+toggle in the card, not saved). They come from one `top -b -d2` that runs only
+while the page is open. A frame is published once top's output goes quiet,
+and the first is skipped because top computes it over each process's whole
+life, so rows appear about 2s after opening. CPU is per core, as top shows it, so a
+busy process can exceed 100%.
+
The cache card runs `du -B1 -d1` once each time the page opens, 28ms over
2 GiB, instead of reading conky's 15-minute `cachesize.txt`. The largest
child is yellow, red when it holds over half the total, as conky had it.
diff --git a/desktop/modules/perf/test_perf.js b/desktop/modules/perf/test_perf.js
index ac5859d..f2caa94 100644
--- a/desktop/modules/perf/test_perf.js
+++ b/desktop/modules/perf/test_perf.js
@@ -20,7 +20,7 @@ const src = fs.readFileSync(__dirname + "/Perf.js", "utf8")
.replace(/^\.pragma library$/m, "");
const P = new Function(src + "\nreturn { cpuTimes, load, meminfo, df, vram, rate, " +
"hwmonMap, lanAddr, cpuModel, du, band, worst, isIPv4, parsePubip, formatPubip, " +
- "ipCheck, due, push, bytes, speed };")();
+ "ipCheck, due, push, bytes, speed, procs };")();
// cpuTimes: aggregate first, then cores; idle is idle + iowait.
const stat = [
@@ -156,4 +156,19 @@ assert.strictEqual(cache.total, 20);
assert.deepStrictEqual(cache.items, [{ name: "a b", bytes: 9 }, { name: "b", bytes: 5 }]);
assert.strictEqual(P.du(""), null);
+// procs: rows after the header only, RES suffixes scaled, spaced names kept.
+const frame = [
+ "top - 09:50:01 up 34 min, 1 user, load average: 1.02, 0.80, 0.60",
+ "Tasks: 412 total, 1 running",
+ "",
+ " PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND",
+ " 8733 you 20 0 3824328 1.0g 140312 S 26.9 3.2 2:23.27 Isolated Web Co",
+ " 3772 you 20 0 1311072 191708 126420 S 5.0 0.6 2:07.86 Hyprland",
+].join("\n");
+assert.deepStrictEqual(P.procs(frame), [
+ { pid: 8733, name: "Isolated Web Co", cpu: 26.9, mem: 1024 ** 3 },
+ { pid: 3772, name: "Hyprland", cpu: 5, mem: 191708 * 1024 },
+]);
+assert.deepStrictEqual(P.procs(""), []);
+
console.log("test_perf: ok");