Refine Framework monitoring history and configurable tray telemetry

This commit is contained in:
ajp_anton
2026-09-11 20:47:09 +00:00
parent 0ec628e786
commit b8cb9dbddb
20 changed files with 847 additions and 83 deletions
+51 -18
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: MIT
#include "chart.h"
#include "graphdata.h"
#include <QPainter>
#include <QPainterPath>
#include <QMouseEvent>
@@ -90,11 +91,18 @@ void Chart::setSelected(const QString &id, bool selected)
} else m_selected.remove(id);
update();
}
QColor Chart::color(const QString &id) const { return m_series.value(id).color; }
QVector<QPointF> Chart::history(const QString &id) const
QColor Chart::color(const QString &id) const
{
const auto series = m_series.constFind(id);
return series == m_series.cend() ? QColor() : series->color;
}
QVector<QPointF> Chart::history(const QString &id, double since) const
{
QVector<QPointF> points;
for (const auto &point : m_series.value(id).points) {
const auto series = m_series.constFind(id);
if (series == m_series.cend()) return points;
for (auto it = historyStart(series->points, since); it != series->points.cend(); ++it) {
const auto &point = *it;
if (!points.isEmpty() && (point.x() - points.last().x() > m_interval * 3 || crossesSleep(points.last().x(), point.x())))
points.append({point.x(), NAN});
points.append(point);
@@ -107,9 +115,8 @@ void Chart::sample(const QMap<QString, double> &values, int intervalMs, qint64 n
m_sampleTime = now;
double oldest = now;
for (auto it = m_series.begin(); it != m_series.end(); ++it) {
it->points.append({double(now), values.value(it.key(), std::numeric_limits<double>::quiet_NaN())});
if (it->points.size() > 600) it->points.remove(0, it->points.size() - 600);
oldest = std::min(oldest, it->points.first().x());
appendSample(it->points, {double(now), values.value(it.key(), std::numeric_limits<double>::quiet_NaN())});
oldest = std::min(oldest, it->points.front().x());
}
m_bands.removeIf([oldest](const Band &band) { return band.end && band.end < oldest; });
update();
@@ -132,21 +139,40 @@ bool Chart::crossesSleep(double from, double to) const
if (band.type == BandType::Sleep && band.begin < to && (!band.end || band.end > from)) return true;
return false;
}
void Chart::setHistoryWindow(qint64 spanMs, bool stretch)
{
m_historyMs = std::clamp(spanMs, qint64(300000), historyRetentionMs);
m_stretch = stretch;
update();
}
QPair<double, double> Chart::timeRange() const
{
const double now = m_sampleTime;
double first = now - m_historyMs;
if (m_stretch) {
double oldest = now;
for (const auto &series : m_series)
if (!series.points.empty()) oldest = std::min(oldest, series.points.front().x());
first = std::max(first, std::min(oldest, now - 10000));
}
return {first, now};
}
Chart::Frame Chart::frame() const
{
// Hover repaints must not move the data or open a gap after the latest sample.
const double now = m_sampleTime;
double first = now, low = 0, high = m_unit == "%" ? 100 : 1, low2 = 0, high2 = 1;
const auto [first, now] = timeRange();
double low = 0, high = m_unit == "%" ? 100 : 1, low2 = 0, high2 = 1;
bool secondary = false;
const auto fm = fontMetrics();
int unitWidth = fm.horizontalAdvance(m_unit);
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
if (!it->points.isEmpty()) first = std::min(first, it->points.first().x());
if (!selected(it.key())) continue;
const bool right = it->unit != m_unit;
secondary |= right;
unitWidth = std::max(unitWidth, fm.horizontalAdvance(it->unit));
for (const auto &point : it->points) if (std::isfinite(point.y())) {
for (auto sample = historyStart(it->points, first); sample != it->points.cend(); ++sample) {
const auto &point = *sample;
if (!std::isfinite(point.y()) || point.x() < first) continue;
(right ? low2 : low) = std::min(right ? low2 : low, point.y());
(right ? high2 : high) = std::max(right ? high2 : high, point.y());
}
@@ -155,7 +181,7 @@ Chart::Frame Chart::frame() const
const int margin = std::max(fm.horizontalAdvance("99999") + 10, unitWidth + timeTextWidth / 2 + 8);
const QRectF area(margin, fm.height() / 2 + 4, width() - margin - (secondary ? margin : 25), height() - 2 * fm.height() - 20);
const int intervals = std::max(2, int(area.height()) / (fm.height() * 2));
return {area, std::min(first, now - 10000), now, AxisTicks::covering(low, high, intervals),
return {area, first, now, AxisTicks::covering(low, high, intervals),
AxisTicks::covering(low2, high2, intervals), secondary};
}
QString Chart::readingAt(qint64 time) const
@@ -241,14 +267,21 @@ void Chart::paintEvent(QPaintEvent *)
bool hasValues = false;
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
if (!selected(it.key())) continue;
QPainterPath line; bool connected = false; double previous = 0;
for (const auto &point : it->points) {
const auto columns = timeAverages(historyStart(it->points, f.first), it->points.cend(),
f.first, f.last, std::ceil(area.width() * devicePixelRatioF()), [this](const QPointF &a, const QPointF &b) {
return b.x() - a.x() <= m_interval * 3 && !crossesSleep(a.x(), b.x());
});
const auto &scale = it->unit == m_unit ? f.left : f.right;
QColor shade = it->color; shade.setAlphaF(.20);
for (const auto &column : columns)
p.fillRect(QRectF(QPointF(x(column.begin), y(column.maximum, scale)),
QPointF(x(column.end), y(column.mean, scale))), shade);
QPainterPath line; bool connected = false;
for (const auto &point : averageLine(columns)) {
if (!std::isfinite(point.y())) { connected = false; continue; }
hasValues = true;
const QPointF position(x(point.x()), y(point.y(), it->unit == m_unit ? f.left : f.right));
if (connected && point.x() - previous <= m_interval * 3 && !crossesSleep(previous, point.x())) line.lineTo(position);
else line.moveTo(position);
connected = true; previous = point.x();
const QPointF position(x(point.x()), y(point.y(), scale));
if (connected) line.lineTo(position); else line.moveTo(position);
connected = true; hasValues = true;
}
p.setPen(QPen(it->color, 2)); p.drawPath(line);
}
+7 -2
View File
@@ -5,6 +5,7 @@
#include <QMap>
#include <QSet>
#include <optional>
#include "history.h"
struct AxisTicks {
double minimum, maximum, step;
@@ -26,7 +27,9 @@ public:
qint64 now = QDateTime::currentMSecsSinceEpoch());
QString readingAt(qint64 time) const;
QColor color(const QString &id) const;
QVector<QPointF> history(const QString &id) const;
QVector<QPointF> history(const QString &id, double since = 0) const;
void setHistoryWindow(qint64 spanMs, bool stretch);
QPair<double, double> timeRange() const;
void setHoverTime(qint64 time) { m_hoverTime = time < 0 ? std::nullopt : std::optional<qint64>(time); update(); }
std::optional<qint64> hoverTime() const { return m_hoverTime; }
Q_SIGNALS:
@@ -36,7 +39,7 @@ protected:
void mouseMoveEvent(QMouseEvent *event) override;
void leaveEvent(QEvent *event) override;
private:
struct Series { QColor color; QString name, unit; QVector<QPointF> points; };
struct Series { QColor color; QString name, unit; Samples points; };
enum class BandType { Sleep, Plugged };
struct Band { qint64 begin, end; BandType type; };
struct Frame { QRectF area; double first, last; AxisTicks left, right; bool secondary; };
@@ -47,6 +50,8 @@ private:
QVector<Band> m_bands;
QString m_unit;
int m_interval = 1000;
qint64 m_historyMs = historyRetentionMs;
bool m_stretch = true;
qint64 m_sampleTime = QDateTime::currentMSecsSinceEpoch();
std::optional<qint64> m_hoverTime;
};
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QPointF>
#include <QVector>
#include <algorithm>
#include <cmath>
struct AverageColumn {
double begin, end, mean, maximum;
bool startsRun;
};
// Integrate the piecewise-linear signal over each pixel's time interval.
// Gaps split runs, even within one pixel; absent time is never counted as zero.
template<class Iterator, class Connected>
QVector<AverageColumn> timeAverages(Iterator begin, Iterator end, double first,
double last, int width, Connected connected)
{
QVector<AverageColumn> result;
if (begin == end || width <= 0 || last <= first) return result;
const double step = (last - first) / width;
bool startsRun = true;
int previousColumn = -1;
auto previous = begin++;
for (; begin != end; previous = begin++) {
const auto a = *previous, b = *begin;
if (!std::isfinite(a.y()) || !std::isfinite(b.y()) || b.x() <= a.x() || !connected(a, b)) {
startsRun = true; continue;
}
double left = std::max(first, a.x());
const double right = std::min(last, b.x());
int column = std::max(0, int(std::floor((left - first) / step)));
for (; left < right && column < width; ++column) {
const double stop = std::min(right, first + (column + 1) * step);
if (stop <= left) continue; // A boundary can round back to the preceding column.
const auto valueAt = [&](double t) { return a.y() + (b.y() - a.y()) * ((t - a.x()) / (b.x() - a.x())); };
const double low = valueAt(left), high = valueAt(stop);
const double mean = (low + high) / 2, maximum = std::max(low, high);
if (!startsRun && previousColumn == column) {
auto &bucket = result.last();
bucket.mean += (mean - bucket.mean) * ((stop - left) / (stop - bucket.begin));
bucket.maximum = std::max(bucket.maximum, maximum);
bucket.end = stop;
} else result.append({left, stop, mean, maximum, startsRun});
startsRun = false; previousColumn = column; left = stop;
}
}
return result;
}
inline QVector<QPointF> averageLine(const QVector<AverageColumn> &columns)
{
QVector<QPointF> points;
for (int i = 0; i < columns.size(); ++i) {
const auto &column = columns[i];
if (column.startsRun) {
if (!points.isEmpty()) points.append({column.begin, NAN});
points.append({column.begin, column.mean});
}
points.append({(column.begin + column.end) / 2, column.mean});
if (i + 1 == columns.size() || columns[i + 1].startsRun)
points.append({column.end, column.mean});
}
return points;
}
+3
View File
@@ -131,6 +131,9 @@ QVariantMap batteryStatus(const QString &sys)
result["fullMWh"] = result["charge_full"].toDouble() * result["voltage_min_design"].toDouble() / 1e9;
result["capacityEstimated"] = true;
}
if (result.contains("energy_now")) result["remainingMWh"] = result["energy_now"].toDouble() / 1000;
else if (result.contains("charge_now") && result["voltage_min_design"].toDouble() > 0)
result["remainingMWh"] = result["charge_now"].toDouble() * result["voltage_min_design"].toDouble() / 1e9;
return result;
}
return {};
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QPointF>
#include <algorithm>
#include <deque>
inline constexpr qint64 historyRetentionMs = 24 * 60 * 60 * 1000;
using Samples = std::deque<QPointF>;
inline void appendSample(Samples &points, QPointF point)
{
points.push_back(point);
// Keep one boundary sample for clipping a segment at the left edge.
while (points.size() > 1 && points[1].x() <= point.x() - historyRetentionMs)
points.pop_front();
}
inline Samples::const_iterator historyStart(const Samples &points, double since)
{
auto first = std::lower_bound(points.cbegin(), points.cend(), since,
[](const QPointF &point, double time) { return point.x() < time; });
return first == points.cbegin() ? first : first - 1;
}
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT
#include "processusage.h"
#include "hardware.h"
#include <KApplicationTrader>
#include <KService>
#include <QDir>
#include <QFileInfo>
#include <QProcess>
#include <QSettings>
#include <QSet>
#include <algorithm>
ProcessUsage::App ProcessUsage::application(const QString &directory, const QString &command)
{
if (!m_loadedApps) {
QSet<QString> ambiguous{"env", "flatpak", "sh", "bash", "python", "python3", "java", "node"};
for (const auto &service : KApplicationTrader::query([](const KService::Ptr &s) { return !s->noDisplay(); })) {
const auto args = QProcess::splitCommand(service->exec());
if (args.isEmpty()) continue;
const auto executable = QFileInfo(args.first()).fileName();
if (ambiguous.contains(executable)) continue;
if (m_apps.contains(executable) && m_apps[executable].id != service->storageId()) {
m_apps.remove(executable); ambiguous.insert(executable);
} else m_apps.insert(executable, {service->storageId(), service->name()});
}
m_loadedApps = true;
}
// Flatpak processes can use generic executables; their sandbox supplies the app ID.
const QString flatpak = directory + "/root/.flatpak-info";
if (QFileInfo::exists(flatpak)) {
QSettings info(flatpak, QSettings::IniFormat);
const QString id = info.value("Application/name").toString();
if (!id.isEmpty()) {
const auto service = KService::serviceByStorageId(id + ".desktop");
return {id, service ? service->name() : id};
}
}
const QString executable = QFileInfo(directory + "/exe").symLinkTarget();
const QString name = executable.isEmpty() ? command : QFileInfo(executable).fileName();
return m_apps.value(name, {executable.isEmpty() ? command : executable, name});
}
QVector<BusyApp> ProcessUsage::sample(std::optional<quint64> totalDelta, const QString &proc)
{
QMap<int, Process> current;
QMap<QString, quint64> usage;
QMap<QString, QString> names;
// Processes may exit or deny access while being read. Skip those snapshots.
for (const auto &entry : QDir(proc).entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
bool ok;
const int pid = entry.toInt(&ok); if (!ok) continue;
const QString directory = proc + "/" + entry;
const QString stat = readText(directory + "/stat");
const int open = stat.indexOf('('), close = stat.lastIndexOf(')');
if (open < 0 || close < open) continue;
const auto fields = stat.mid(close + 1).simplified().split(' ');
if (fields.size() < 20) continue;
// proc_pid_stat(5): fields 14, 15 and 22, after removing PID and comm.
// https://man7.org/linux/man-pages/man5/proc_pid_stat.5.html
const auto user = fields[11].toULongLong(&ok); if (!ok) continue;
const auto system = fields[12].toULongLong(&ok); if (!ok) continue;
const auto start = fields[19].toULongLong(&ok); if (!ok) continue;
const auto old = m_previous.constFind(pid);
const bool same = old != m_previous.cend() && old->start == start;
const QString command = stat.mid(open + 1, close - open - 1);
const auto app = same && old->command == command ? old->app : application(directory, command);
const quint64 ticks = user + system;
current.insert(pid, {start, ticks, command, app});
if (same && ticks >= old->ticks) {
usage[app.id] += ticks - old->ticks;
names[app.id] = app.name.simplified();
}
}
m_previous = std::move(current);
if (!totalDelta || !*totalDelta || usage.isEmpty()) return {};
QVector<BusyApp> apps;
for (auto it = usage.cbegin(); it != usage.cend(); ++it)
apps.append({names[it.key()], std::min(100., 100. * *it / *totalDelta)});
std::stable_sort(apps.begin(), apps.end(), [](const BusyApp &a, const BusyApp &b) {
return a.percent == b.percent ? a.name < b.name : a.percent > b.percent;
});
if (apps.size() > 3) apps.resize(3);
return apps;
}
+20
View File
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QMap>
#include <QString>
#include <QVector>
#include <optional>
struct BusyApp { QString name; double percent; };
class ProcessUsage {
public:
QVector<BusyApp> sample(std::optional<quint64> totalDelta, const QString &proc = "/proc");
void reset() { m_previous.clear(); }
private:
struct App { QString id, name; };
struct Process { quint64 start, ticks; QString command; App app; };
App application(const QString &directory, const QString &command);
QMap<int, Process> m_previous;
QMap<QString, App> m_apps;
bool m_loadedApps = false;
};
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT
#include "tooltip.h"
#include <cmath>
#include <algorithm>
QVector<TooltipSensor> tooltipSensors(const QVector<Sensor> &sensors)
{
QVector<TooltipSensor> result{{"cpu", "CPU", {}}, {"memory", "RAM", {}}, {"nvme", "NVMe", {}},
{"battery", "Battery", {}}, {"board", "Board", {}}};
const QStringList prefixes{"cros_ec/peci-temp", "spd5118/", "nvme/Composite", "cros_ec/battery_temp@b", "cros_ec/local_f75397@4c"};
for (int i = 0; i < result.size(); ++i) {
for (const auto &sensor : sensors) if (sensor.id.startsWith(prefixes[i])) { result[i].id = sensor.id; break; }
if (i == 0 && result[i].id.isEmpty())
for (const auto &sensor : sensors) if (sensor.id.startsWith("coretemp/Package id")) { result[i].id = sensor.id; break; }
}
return result;
}
bool tooltipEnabled(const QVariantMap &settings, const QString &key)
{
return settings.value("tray/hover/" + key, true).toBool();
}
int tooltipAppCount(const QVariantMap &settings)
{
return std::clamp(settings.value("tray/hover/topApps", settings.value("tray/hover/topApp", false).toBool() ? 1 : 0).toInt(), 0, 3);
}
QString batteryTooltip(const QVariantMap &battery, int limit, const QVariantMap &upower)
{
if (battery.isEmpty()) return "Battery unavailable";
QString text = "Battery " + (battery.contains("capacity") ? QString::number(battery["capacity"].toInt()) + "%" : "");
const auto energy = [&](const QString &key) {
const double value = battery.value(key, NAN).toDouble();
if (!std::isfinite(value) || value < 0) return QString("");
QString number = QString::number(qRound64(value));
for (int i = number.size() - 3; i > 0; i -= 3) number.insert(i, ' ');
return number;
};
text += " · " + energy("remainingMWh") + " mWh / " + energy("fullMWh") + " mWh";
const auto watts = batteryRate(battery);
text += "\n\u2003" + (watts ? (*watts > 0 ? "+" : "") + QString::number(*watts, 'f', 1) + " W" : "— W");
const bool charging = battery["state"] == "Charging", discharging = battery["state"] == "Discharging";
double seconds = 0;
bool approximate = false;
if (discharging) seconds = upower.value("TimeToEmpty").toDouble();
else if (charging && limit == 100) seconds = upower.value("TimeToFull").toDouble();
else if (charging && limit < 100) {
approximate = true;
if (battery["current_now"].toDouble() > 0 && battery.contains("charge_now") && battery["charge_full"].toDouble() > 0)
seconds = (battery["charge_full"].toDouble() * limit / 100 - battery["charge_now"].toDouble()) / battery["current_now"].toDouble() * 3600;
else if (watts && *watts > 0 && battery.contains("energy_now") && battery["energy_full"].toDouble() > 0)
seconds = (battery["energy_full"].toDouble() * limit / 100 - battery["energy_now"].toDouble()) / (*watts * 1e6) * 3600;
}
if (std::isfinite(seconds) && seconds > 0) {
const auto minutes = qint64(std::ceil(seconds / 60));
text += QString(" · %1%2 h %3 min to %4%").arg(approximate ? "" : "")
.arg(minutes / 60).arg(minutes % 60).arg(discharging ? 0 : limit);
} else if (charging && battery.contains("capacity") && battery["capacity"].toInt() >= limit)
text += QString(" · %1% limit reached").arg(limit);
else if (charging || discharging) text += " · Time remaining unavailable";
else text += " · " + battery["state"].toString();
return text;
}
QString trayTooltip(const QVariantMap &settings, const QMap<QString, double> &values,
const QVector<TooltipSensor> &temperatures, const QString &battery, const QStringList &topApps)
{
const auto reading = [&](const QString &id, int decimals) {
const auto it = values.constFind(id);
return it != values.cend() && std::isfinite(*it) ? QString::number(*it, 'f', decimals) : QString("");
};
QStringList lines;
if (tooltipEnabled(settings, "cpu")) {
lines << "CPU usage: " + reading("cpu-usage", 1) + " %";
const int count = tooltipAppCount(settings);
if (count && topApps.isEmpty()) lines << "\u2003CPU app readings unavailable";
for (int i = 0; i < std::min(count, int(topApps.size())); ++i) lines << "\u2003" + topApps[i];
}
if (tooltipEnabled(settings, "battery")) lines << battery;
if (tooltipEnabled(settings, "fan")) lines << "Fan speed: " + reading("fan", 0) + " RPM (" + reading("fan-duty", 0) + " %)";
QStringList temps;
for (const auto &sensor : temperatures)
if (!sensor.id.isEmpty() && tooltipEnabled(settings, "temperature/" + sensor.key))
temps << sensor.name + " " + reading(sensor.id, 0) + "°C";
if (temps.size() > 1) lines << "Temperatures:";
for (const auto &temp : temps) lines << (temps.size() > 1 ? "\u2003" : "") + temp;
return lines.join('\n');
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "hardware.h"
struct TooltipSensor { QString key, name, id; };
QVector<TooltipSensor> tooltipSensors(const QVector<Sensor> &sensors);
bool tooltipEnabled(const QVariantMap &settings, const QString &key);
int tooltipAppCount(const QVariantMap &settings);
QString batteryTooltip(const QVariantMap &battery, int limit, const QVariantMap &upower);
QString trayTooltip(const QVariantMap &settings, const QMap<QString, double> &values,
const QVector<TooltipSensor> &temperatures, const QString &battery, const QStringList &topApps);
+26 -10
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: MIT
#include "tray.h"
#include "graphdata.h"
#include <QPainter>
#include <QPainterPath>
#include <cmath>
@@ -7,6 +8,7 @@
std::optional<double> CpuUsage::sample(const QString &procStat)
{
m_delta.reset();
const auto fields = procStat.section('\n', 0, 0).simplified().split(' ');
if (fields.size() < 5 || fields.first() != "cpu") { reset(); return {}; }
Counters current{0, 0};
@@ -21,6 +23,7 @@ std::optional<double> CpuUsage::sample(const QString &procStat)
if (!previous || current.total <= previous->total || current.idle < previous->idle) return {};
const auto total = current.total - previous->total, idle = current.idle - previous->idle;
if (idle > total) return {};
m_delta = total;
return 100. * (total - idle) / total;
}
QRectF trayPlotRect(bool border)
@@ -28,7 +31,7 @@ QRectF trayPlotRect(bool border)
// Two-pixel border; leave room for the graph's two-pixel stroke inside it.
return border ? QRectF(3, 3, 58, 58) : QRectF(1, 1, 62, 62);
}
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style)
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style, qint64 now)
{
QPixmap pixmap(64, 64); pixmap.fill(style.transparent ? Qt::transparent : style.backgroundColor);
QPainter p(&pixmap); p.setRenderHint(QPainter::Antialiasing);
@@ -37,15 +40,27 @@ QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &u
const double latest = values.isEmpty() ? NAN : values.last().y();
p.setPen(style.lineColor);
if (graph && !values.isEmpty() && style.maximum > style.minimum) {
const double first = values.first().x(), span = std::max(1., values.last().x() - first);
const double last = now;
const double first = last - style.historyMs;
const double span = std::max(1., last - first);
p.save(); p.setClipRect(area.adjusted(-1, -1, 1, 1));
const auto position = [&](const QPointF &point) {
const double fraction = std::clamp((point.y() - style.minimum) / (style.maximum - style.minimum), 0., 1.);
return QPointF(area.left() + (point.x() - first) / span * area.width(), area.bottom() - fraction * area.height());
};
const auto outside = [&](double y) { return y < style.minimum || y > style.maximum; };
const auto color = [&](bool out) { return out && style.overflowColor ? style.outsideColor : style.lineColor; };
for (int i = 1; i < values.size(); ++i) {
const auto a = values[i - 1], b = values[i];
const auto columns = timeAverages(values.cbegin(), values.cend(), first, last, std::ceil(area.width()),
[](const QPointF &, const QPointF &) { return true; });
for (const auto &column : columns) {
QColor shade = style.lineColor; shade.setAlphaF(shade.alphaF() * .20);
p.fillRect(QRectF(position({column.begin, column.maximum}), position({column.end, column.mean})), shade);
}
const auto averages = averageLine(columns);
QPainterPath line, overflow, fill;
for (int i = 1; i < averages.size(); ++i) {
const auto a = averages[i - 1], b = averages[i];
if (b.x() < first || a.x() > last) continue;
if (!std::isfinite(a.y()) || !std::isfinite(b.y())) continue;
QVector<double> cuts{0, 1};
if (a.y() != b.y()) for (double boundary : {style.minimum, style.maximum}) {
@@ -58,16 +73,17 @@ QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &u
if (out && !style.clamp) continue;
const auto left = position(a + (b - a) * cuts[j - 1]), right = position(a + (b - a) * cuts[j]);
if (style.fill) {
QPainterPath fill; fill.moveTo(left); fill.lineTo(right);
fill.moveTo(left); fill.lineTo(right);
fill.lineTo(right.x(), area.bottom()); fill.lineTo(left.x(), area.bottom()); fill.closeSubpath();
p.fillPath(fill, style.fillColor);
}
p.setPen(QPen(color(out), 2, Qt::SolidLine, Qt::FlatCap)); p.drawLine(left, right);
auto &path = out ? overflow : line;
path.moveTo(left); path.lineTo(right);
}
}
for (const auto &point : values) if (std::isfinite(point.y()) && (style.clamp || !outside(point.y()))) {
p.setPen(QPen(color(outside(point.y())), 2, Qt::SolidLine, Qt::RoundCap)); p.drawPoint(position(point));
}
if (style.fill) p.fillPath(fill, style.fillColor);
p.setPen(QPen(color(false), 2, Qt::SolidLine, Qt::RoundCap)); p.drawPath(line);
p.setPen(QPen(color(true), 2, Qt::SolidLine, Qt::RoundCap)); p.drawPath(overflow);
p.restore();
} else {
const QString value = !std::isfinite(latest) ? "" : unit == "MHz"
? QString::number(latest / 1000, 'f', 1) : QString::number(std::round(latest), 'f', 0);
+7 -2
View File
@@ -3,20 +3,25 @@
#include <QIcon>
#include <QPalette>
#include <QVector>
#include <QDateTime>
#include <optional>
class CpuUsage {
public:
std::optional<double> sample(const QString &procStat);
void reset() { m_previous.reset(); }
void reset() { m_previous.reset(); m_delta.reset(); }
std::optional<quint64> totalDelta() const { return m_delta; }
private:
struct Counters { quint64 total, idle; };
std::optional<Counters> m_previous;
std::optional<quint64> m_delta;
};
struct TrayStyle {
bool border = true, transparent = false, fill = true, clamp = true, overflowColor = true;
QColor borderColor, backgroundColor, fillColor, lineColor, outsideColor;
double minimum = 0, maximum = 100;
qint64 historyMs = 60000;
};
QRectF trayPlotRect(bool border);
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style);
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style,
qint64 now = QDateTime::currentMSecsSinceEpoch());
+62 -8
View File
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: MIT
#include "traypage.h"
#include "colorbutton.h"
#include "tooltip.h"
#include <QGroupBox>
#include <QSpinBox>
#include <QVBoxLayout>
#include <QPushButton>
#include <QColorDialog>
@@ -19,7 +22,11 @@ TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget
for (const auto &sensor : sensors) m_metric->addItem(sensor.name + " (" + sensor.unit + ")", sensor.id);
m_mode->setCurrentIndex(std::max(0, m_mode->findData(m_values.value("tray/mode", "icon"))));
m_metric->setCurrentIndex(std::max(0, m_metric->findData(m_values.value("tray/metric", "cpu-usage"))));
form->addRow("Display:", m_mode); form->addRow("Reading:", m_metric);
form->addRow("Display:", m_mode);
auto *graphOptions = new QWidget; graphOptions->setObjectName("trayGraphOptions");
auto *graphForm = new QFormLayout(graphOptions); graphForm->setContentsMargins(24, 0, 0, 0);
graphForm->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
form->addRow(graphOptions); form->addRow("Reading:", m_metric);
auto *appearance = new QWidget; auto *colors = new QFormLayout(appearance); colors->setContentsMargins(0, 0, 0, 0);
colors->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
auto colorRow = [&](QFormLayout *target, const QString &label, const QString &key, const QColor &fallback, bool alpha = false) {
@@ -57,6 +64,23 @@ TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget
layout->addWidget(appearance);
m_graphSettings = new QWidget; m_scaleLayout = new QFormLayout(m_graphSettings); m_scaleLayout->setContentsMargins(0, 0, 0, 0);
m_scaleLayout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
m_history = new QComboBox; m_history->setObjectName("trayHistory");
for (int seconds : {10, 15, 20, 30})
m_history->addItem(QString::number(seconds) + " seconds", seconds * 1000);
for (int minutes : {1, 2, 5})
m_history->addItem(QString::number(minutes) + (minutes == 1 ? " minute" : " minutes"), minutes * 60000);
const int savedHistory = m_history->findData(m_values.value("tray/historyMs", 60000));
m_history->setCurrentIndex(savedHistory < 0 ? m_history->findData(60000) : savedHistory);
m_history->setToolTip("Fixed spans end at now. Only retained samples are shown; missing history stays blank.");
graphForm->addRow("History:", m_history);
m_values.insert("tray/historyMs", m_history->currentData());
connect(m_history, &QComboBox::currentIndexChanged, this, [this] {
m_values.insert("tray/historyMs", m_history->currentData()); if (!m_loading) Q_EMIT settingsChanged();
});
m_reload.append([this] {
const int saved = m_history->findData(m_values.value("tray/historyMs", 60000));
m_history->setCurrentIndex(saved < 0 ? m_history->findData(60000) : saved);
});
m_minimum = new QDoubleSpinBox; m_minimum->setObjectName("trayMinimum");
m_maximum = new QDoubleSpinBox; m_maximum->setObjectName("trayMaximum");
m_scaleLayout->addRow("Graph minimum:", m_minimum); m_scaleLayout->addRow("Graph maximum:", m_maximum);
@@ -66,21 +90,25 @@ TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget
m_outside = new QComboBox; m_outside->setObjectName("trayOutside");
m_outside->addItem("Draw along the inner edge", "clamp"); m_outside->addItem("Do not draw outside readings", "hide");
m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
m_scaleLayout->addRow("Outside graph range:", m_outside);
m_overflowColor = check(m_scaleLayout, "Use a different colour outside the range", "overflowColor", true);
auto *outsideColor = colorRow(m_scaleLayout, "Outside-range colour:", "outsideColor", QColor("#f67400"));
auto syncOutside = [this, outsideColor] {
m_overflowColor->setEnabled(m_outside->currentData() == "clamp");
outsideColor->setEnabled(m_overflowColor->isEnabled() && m_overflowColor->isChecked());
graphForm->addRow("Out of range:", m_outside);
auto *overflowOptions = new QWidget; overflowOptions->setObjectName("trayOverflowOptions");
auto *overflowForm = new QFormLayout(overflowOptions); overflowForm->setContentsMargins(24, 0, 0, 0);
overflowForm->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); graphForm->addRow(overflowOptions);
m_overflowColor = check(overflowForm, "Use a different colour outside the range", "overflowColor", true);
auto *outsideColor = colorRow(overflowForm, "Colour:", "outsideColor", QColor("#f67400"));
auto syncOutside = [this, outsideColor, overflowOptions] {
overflowOptions->setVisible(m_outside->currentData() == "clamp");
outsideColor->setEnabled(m_overflowColor->isChecked());
};
connect(m_overflowColor, &QCheckBox::toggled, this, syncOutside);
connect(m_outside, &QComboBox::currentIndexChanged, this, [this, syncOutside] {
m_values.insert("tray/outside", m_outside->currentData()); syncOutside(); if (!m_loading) Q_EMIT settingsChanged();
});
syncOutside(); layout->addWidget(m_graphSettings);
auto syncMode = [this, appearance] {
auto syncMode = [this, appearance, graphOptions] {
const bool telemetry = mode() != "icon";
m_metric->setEnabled(telemetry); appearance->setVisible(telemetry); m_graphSettings->setVisible(mode() == "graph");
graphOptions->setVisible(mode() == "graph");
};
connect(m_mode, &QComboBox::currentIndexChanged, this, [this, syncMode] {
m_values.insert("tray/mode", m_mode->currentData()); syncMode(); if (!m_loading) Q_EMIT settingsChanged();
@@ -104,6 +132,31 @@ TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget
m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
syncMode(); syncOutside();
});
auto *hover = new QGroupBox("Hover information"); auto *hoverForm = new QFormLayout(hover);
auto *cpu = check(hoverForm, "CPU usage", "hover/cpu", true);
auto *appOptions = new QWidget; auto *appForm = new QHBoxLayout(appOptions); appForm->setContentsMargins(24, 0, 0, 0);
auto *topApps = new QSpinBox; topApps->setObjectName("hover/topApps"); topApps->setRange(0, 3);
topApps->setValue(tooltipAppCount(m_values));
m_values.remove("tray/hover/topApp"); m_values.insert("tray/hover/topApps", topApps->value());
appForm->addWidget(topApps); appForm->addWidget(new QLabel("apps using the most CPU")); appForm->addStretch();
hoverForm->addRow(appOptions);
topApps->setToolTip("0 turns this off. Percentages use total CPU capacity; processes are grouped by application where identifiable.");
m_reload.append([this, topApps] { topApps->setValue(tooltipAppCount(m_values)); });
connect(topApps, &QSpinBox::valueChanged, this, [this](int count) {
m_values.insert("tray/hover/topApps", count); if (!m_loading) Q_EMIT settingsChanged();
});
appOptions->setEnabled(cpu->isChecked()); connect(cpu, &QCheckBox::toggled, appOptions, &QWidget::setEnabled);
check(hoverForm, "Battery", "hover/battery", true);
check(hoverForm, "Fan", "hover/fan", true);
hoverForm->addRow(new QLabel("Temperature:"));
auto *temperatures = new QWidget; auto *tempForm = new QFormLayout(temperatures); tempForm->setContentsMargins(24, 0, 0, 0);
for (const auto &sensor : tooltipSensors(sensors)) {
const QString label = sensor.key == "memory" ? "Memory" : sensor.key == "board" ? "Mainboard" : sensor.name;
auto *box = check(tempForm, label, "hover/temperature/" + sensor.key, !sensor.id.isEmpty());
box->setEnabled(!sensor.id.isEmpty());
if (sensor.id.isEmpty()) box->setToolTip("No matching temperature sensor detected.");
}
hoverForm->addRow(temperatures); layout->addWidget(hover);
layout->addStretch();
}
void TrayPage::load(const QVariantMap &values)
@@ -133,6 +186,7 @@ TrayStyle TrayPage::iconStyle() const
return value.isValid() ? value : fallback;
};
TrayStyle s;
s.historyMs = m_history->currentData().toLongLong();
s.border = m_border->isChecked(); s.transparent = m_transparent->isChecked(); s.fill = m_fill->isChecked();
s.clamp = m_outside->currentData() == "clamp"; s.overflowColor = m_overflowColor->isChecked();
s.borderColor = color("borderColor", palette().color(QPalette::WindowText));
+1 -1
View File
@@ -28,7 +28,7 @@ private:
QList<std::function<void()>> m_reload;
bool m_loading = false;
QVector<Sensor> m_sensors;
QComboBox *m_mode, *m_metric, *m_outside;
QComboBox *m_mode, *m_metric, *m_outside, *m_history;
QDoubleSpinBox *m_minimum, *m_maximum;
QFormLayout *m_scaleLayout;
QWidget *m_graphSettings;
+47 -31
View File
@@ -2,6 +2,7 @@
#include "window.h"
#include "legend.h"
#include "fan.h"
#include "tooltip.h"
#include <QMessageBox>
#include <KAuth/Action>
#include <KAuth/ExecuteJob>
@@ -22,6 +23,7 @@
#include <QStandardPaths>
#include <QDir>
#include <QFileInfo>
#include <QSlider>
#include <cmath>
namespace {
@@ -30,10 +32,6 @@ ValueControl *spin(int minimum, int maximum, const QString &suffix) {
auto *box = new ValueControl; box->setRange(minimum, maximum); box->setSuffix(suffix); return box;
}
QString duration(double seconds) {
if (!std::isfinite(seconds) || seconds < 60) return "less than a minute";
return QString("%1 h %2 min").arg(int(seconds) / 3600).arg(int(seconds) / 60 % 60);
}
}
Window::Window() : m_settings("fedora-tools", "framework-laptop-tools"),
@@ -157,6 +155,18 @@ QGroupBox *Window::sensorGroup(const QString &title, Chart *chart, const QVector
QWidget *Window::monitorPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *historyRow = new QHBoxLayout;
auto *history = new QSlider(Qt::Horizontal); history->setObjectName("monitorHistory");
// Logarithmic spacing, rounded to whole minutes for a readable selection.
history->setRange(0, 1000);
const auto savedMinutes = std::clamp(m_settings.value("monitor/historyMinutes", 1440).toInt(), 5, 1440);
history->setValue(qRound(1000 * std::log(savedMinutes / 5.) / std::log(288.)));
auto *span = new QLabel; span->setMinimumWidth(fontMetrics().horizontalAdvance("24 h 59 min"));
auto *stretch = new QCheckBox("Stretch available data"); stretch->setObjectName("monitorStretch");
stretch->setChecked(m_settings.value("monitor/stretch", true).toBool());
stretch->setToolTip("Fit the available history until the selected time span has been collected.");
historyRow->addWidget(new QLabel("History shown:")); historyRow->addWidget(history, 1); historyRow->addWidget(span);
layout->addLayout(historyRow); layout->addWidget(stretch);
m_frequencyChart = new Chart("MHz"); m_temperatureChart = new Chart("°C"); m_batteryChart = new Chart("%");
layout->addWidget(sensorGroup("CPU and GPU frequency", m_frequencyChart, m_frequencies, false));
m_fanChart = new Chart("RPM");
@@ -167,6 +177,18 @@ QWidget *Window::monitorPage()
{{"battery", "Charge level", {}, "%"}, {"battery-rate", "Charge / discharge rate", {}, "W"}}, false);
layout->addWidget(batteryGroup);
const QList<Chart *> charts{m_frequencyChart, m_fanChart, m_temperatureChart, m_batteryChart};
auto updateHistory = [this, charts, history, stretch, span] {
const int minutes = qRound(5 * std::pow(288., history->value() / 1000.));
span->setText(minutes < 60 ? QString("%1 min").arg(minutes)
: minutes % 60 ? QString("%1 h %2 min").arg(minutes / 60).arg(minutes % 60)
: QString("%1 h").arg(minutes / 60));
m_settings.setValue("monitor/historyMinutes", minutes);
m_settings.setValue("monitor/stretch", stretch->isChecked());
for (auto *chart : charts) chart->setHistoryWindow(qint64(minutes) * 60000, stretch->isChecked());
};
connect(history, &QSlider::valueChanged, this, updateHistory);
connect(stretch, &QCheckBox::toggled, this, updateHistory);
updateHistory();
for (auto *source : charts) connect(source, &Chart::hovered, this, [charts](qint64 time) {
for (auto *chart : charts) chart->setHoverTime(time);
});
@@ -260,15 +282,18 @@ QWidget *Window::trayPage()
void Window::updateTray()
{
const auto metric = m_trayMetric;
QVector<QPointF> history;
if (metric.id == "cpu-usage") history = m_usageHistory;
else if (metric.id == "battery" || metric.id == "battery-rate") history = m_batteryChart->history(metric.id);
else history = (metric.unit == "MHz" ? m_frequencyChart : m_temperatureChart)->history(metric.id);
if (m_trayMode == "icon") m_tray->setIcon(windowIcon());
else m_tray->setIcon(telemetryIcon(m_trayMode == "graph", history, metric.unit, m_trayStyle));
const auto value = m_values.constFind(metric.id);
m_tray->setToolTip(metric.name + ": " + (value == m_values.cend() ? "" : QString::number(*value, 'f', 1) + " " + metric.unit)
+ "\n" + m_batteryText + "\n" + m_fanReadout->text());
else {
const auto now = QDateTime::currentMSecsSinceEpoch();
const double since = now - (m_trayMode == "graph" ? m_trayStyle.historyMs : 0);
QVector<QPointF> history;
if (metric.id == "cpu-usage")
for (auto it = historyStart(m_usageHistory, since); it != m_usageHistory.cend(); ++it) history.append(*it);
else if (metric.id == "battery" || metric.id == "battery-rate") history = m_batteryChart->history(metric.id, since);
else history = (metric.unit == "MHz" ? m_frequencyChart : m_temperatureChart)->history(metric.id, since);
m_tray->setIcon(telemetryIcon(m_trayMode == "graph", history, metric.unit, m_trayStyle, now));
}
m_tray->setToolTip(trayTooltip(m_savedTray, m_values, tooltipSensors(m_temperatures), m_batteryText, m_topAppText));
}
QVariantMap Window::controlValue(const QString &key) const
{
@@ -487,20 +512,21 @@ void Window::sample()
m_fanReadout->setText((rpm ? QString("%1 RPM").arg(*rpm, 0, 'f', 0) : "Fan speed unavailable")
+ (pwm ? QString(" · Duty: %1%").arg(*pwm * 100 / 255, 0, 'f', 0) : " · Duty unavailable") + " · " + fanMode);
QMap<QString, double> fans;
if (rpm) fans["fan"] = *rpm;
if (rpm) { fans["fan"] = *rpm; m_values["fan"] = *rpm; }
if (pwm) m_values["fan-duty"] = *pwm * 100 / 255;
m_fanChart->sample(fans, m_fastTimer.interval());
const auto battery = batteryStatus();
const auto usage = m_cpuUsage.sample(readText("/proc/stat"));
if (usage) m_values["cpu-usage"] = *usage;
m_usageHistory.append({double(QDateTime::currentMSecsSinceEpoch()), usage.value_or(NAN)});
if (m_usageHistory.size() > 600) m_usageHistory.removeFirst();
if (tooltipEnabled(m_savedTray, "cpu") && tooltipAppCount(m_savedTray) > 0) {
m_topAppText.clear();
for (const auto &app : m_processUsage.sample(m_cpuUsage.totalDelta()))
m_topAppText << app.name + QString(": %1 %").arg(app.percent, 0, 'f', 1);
} else { m_processUsage.reset(); m_topAppText.clear(); }
appendSample(m_usageHistory, {double(QDateTime::currentMSecsSinceEpoch()), usage.value_or(NAN)});
if (battery.contains("capacity")) m_values["battery"] = battery["capacity"].toDouble();
const auto rate = batteryRate(battery); if (rate) m_values["battery-rate"] = *rate;
m_batteryChart->setPowerState(false, onAcPower());
QString text = battery.isEmpty() ? "Battery unavailable" : QString("Battery %1% · %2").arg(battery.value("capacity").toInt()).arg(battery.value("state").toString());
if (battery.contains("watts")) text += QString(" · %1 W").arg(battery["watts"].toDouble(), 0, 'f', 1);
if (battery.contains("health")) text += QString(" · Capacity / design %1%").arg(battery["health"].toDouble(), 0, 'f', 0);
if (battery.contains("cycle_count")) text += QString(" · %1 cycles").arg(battery["cycle_count"].toInt());
QStringList details;
if (battery.contains("fullMWh")) details << QString("Capacity: %1 mWh").arg(battery["fullMWh"].toDouble(), 0, 'f', 0);
if (battery.contains("health")) details << QString("Health: %1%").arg(battery["health"].toDouble(), 0, 'f', 0);
@@ -510,18 +536,7 @@ void Window::sample()
QDBusInterface properties("org.freedesktop.UPower", "/org/freedesktop/UPower/devices/DisplayDevice", "org.freedesktop.DBus.Properties", QDBusConnection::systemBus());
properties.setTimeout(250);
const QDBusReply<QVariantMap> response = properties.call("GetAll", "org.freedesktop.UPower.Device");
if (response.isValid()) {
if (battery["state"] == "Discharging" && response.value()["TimeToEmpty"].toLongLong() > 0)
text += " · " + duration(response.value()["TimeToEmpty"].toDouble()) + " remaining (OS estimate)";
else if (battery["state"] == "Charging" && response.value()["TimeToFull"].toLongLong() > 0 && effectiveLimit == 100)
text += " · " + duration(response.value()["TimeToFull"].toDouble()) + " until full (OS estimate)";
}
if (battery["state"] == "Charging" && effectiveLimit < 100 && battery["current_now"].toDouble() > 0
&& battery.contains("charge_now") && battery["charge_full"].toDouble() > 0) {
const double remaining = battery["charge_full"].toDouble() * effectiveLimit / 100 - battery["charge_now"].toDouble();
text += remaining > 0 ? " · about " + duration(remaining / battery["current_now"].toDouble() * 3600) + QString(" to %1% (at current rate)").arg(effectiveLimit) : " · charge limit reached";
}
m_batteryText = text;
m_batteryText = batteryTooltip(battery, effectiveLimit, response.isValid() ? response.value() : QVariantMap{});
updateTray();
}
void Window::sampleBattery()
@@ -539,6 +554,7 @@ void Window::sleepChanged(bool sleeping)
for (auto *chart : {m_frequencyChart, m_temperatureChart, m_batteryChart, m_fanChart})
chart->setPowerState(sleeping, sleeping || chart != m_batteryChart ? std::nullopt : onAcPower());
m_cpuUsage.reset();
m_processUsage.reset(); m_topAppText.clear();
if (!sleeping) { sample(); sampleBattery(); }
}
void Window::closeEvent(QCloseEvent *event)
+4 -1
View File
@@ -4,6 +4,7 @@
#include "cpupage.h"
#include "tray.h"
#include "traypage.h"
#include "processusage.h"
#include <QMainWindow>
#include <QSettings>
#include <QTimer>
@@ -49,7 +50,9 @@ private:
QVector<Sensor> m_temperatures, m_frequencies;
QMap<QString, double> m_values;
CpuUsage m_cpuUsage;
QVector<QPointF> m_usageHistory;
ProcessUsage m_processUsage;
QStringList m_topAppText;
Samples m_usageHistory;
Chart *m_frequencyChart, *m_temperatureChart, *m_batteryChart, *m_fanChart;
QLabel *m_batteryDetails, *m_fanDetails;
QLabel *m_message, *m_fanReadout, *m_firmwareReadout;