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
+4 -3
View File
@@ -10,14 +10,15 @@ include(KDECMakeSettings)
include(CTest)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Widgets DBus Test)
find_package(KF6Auth REQUIRED)
find_package(KF6Service REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(Systemd REQUIRED IMPORTED_TARGET libsystemd)
add_library(framework-hardware STATIC src/hardware.cpp src/fan.cpp src/cpu.cpp)
target_include_directories(framework-hardware PUBLIC src)
target_link_libraries(framework-hardware PUBLIC Qt6::Core)
set(gui_sources src/window.cpp src/chart.cpp src/legend.cpp src/tray.cpp src/traypage.cpp src/colorbutton.cpp src/cpupage.cpp src/valuecontrol.cpp)
set(gui_sources src/window.cpp src/chart.cpp src/legend.cpp src/tray.cpp src/traypage.cpp src/colorbutton.cpp src/cpupage.cpp src/valuecontrol.cpp src/tooltip.cpp src/processusage.cpp)
add_executable(framework-laptop-tools src/main.cpp ${gui_sources})
target_link_libraries(framework-laptop-tools PRIVATE framework-hardware Qt6::Widgets Qt6::DBus KF6::AuthCore)
target_link_libraries(framework-laptop-tools PRIVATE framework-hardware Qt6::Widgets Qt6::DBus KF6::AuthCore KF6::Service)
add_executable(framework-laptop-tools-helper src/helper.cpp)
target_link_libraries(framework-laptop-tools-helper PRIVATE framework-hardware KF6::AuthCore Qt6::DBus)
add_executable(framework-laptop-tools-fan src/fanservice.cpp)
@@ -39,7 +40,7 @@ if(BUILD_TESTING)
target_link_libraries(test-hardware PRIVATE framework-hardware Qt6::Test)
add_test(NAME hardware COMMAND test-hardware)
add_executable(test-window tests/test-window.cpp ${gui_sources})
target_link_libraries(test-window PRIVATE framework-hardware Qt6::Widgets Qt6::DBus Qt6::Test KF6::AuthCore)
target_link_libraries(test-window PRIVATE framework-hardware Qt6::Widgets Qt6::DBus Qt6::Test KF6::AuthCore KF6::Service)
add_test(NAME window COMMAND test-window)
set_tests_properties(window PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
endif()
+31 -3
View File
@@ -43,7 +43,16 @@ uses the left percentage axis and rate the right watts axis (positive charging,
negative discharging). Blue-grey bands mark logind-observed sleep; faint red bands
mark observed charger connection. Plug changes during sleep are unknown, so AC
bands are not extended through sleep. There is no history from before app startup.
History is bounded to 600 samples per series and kept only while the app runs.
The Monitor history slider spans 5 minutes to 24 hours on a logarithmic scale.
Stretch available data fits shorter recordings to the graph width until the
selected span has been collected. These viewing controls take effect immediately
and are remembered; the default is 24 hours with stretching enabled.
All detected sensors retain 24 hours of readings in memory, even when hidden,
plus one boundary sample for clipping. Quitting the app clears this history.
Monitor and tray lines show time-weighted averages per pixel, assuming straight
lines between readings. A faint band extends from the average to the maximum;
brief peaks remain visible without dominating the solid line. Missing data and
sleep remain gaps. Hover readings still report the original samples.
Sampling choices are 0.5/1/2/4 seconds for frequencies and temperatures, and
15/30/60/120 seconds for the battery graph. Tray autostart is optional.
@@ -51,17 +60,36 @@ Closing the window leaves monitoring in the tray; Quit exits the application.
The Tray icon tab selects a normal icon, a history graph, or a number. Sources
include CPU usage, CPU/GPU frequency, temperatures, charge level and battery rate.
Sensor graphs share Monitor's history, including gaps, regardless of which
sensor is selected for the tray. CPU usage also retains 600 samples. Switching
sensor is selected for the tray. CPU usage also retains 24 hours. Switching
readings or resuming from sleep does not clear history. Frequency numbers use
GHz; the hover tooltip includes the full reading and unit. The tray has optional
GHz. The tray has optional
borders, a transparent or coloured background, line and fill colours, and optional
area fill (including adjustable opacity). Frequency ceilings and temperature
ranges are saved per sensor; battery rate also has adjustable bounds (initially
7575 W), while percentages use 0100.
Tray graphs show 10, 15, 20 or 30 seconds, or 1, 2 or 5 minutes, ending at now.
The default is one minute. History length is staged with the other tray settings.
Older readings are clipped, and unavailable history stays blank.
Outside readings can follow the inner edge, optionally in a different colour,
or be hidden. The line remains inside the border when a border is enabled.
Colour buttons show the opaque RGB swatch and label opacity separately, so a
translucent fill is not mistaken for a darker RGB colour.
History and out-of-range controls are nested under the History graph display
choice. Hover information is configured separately: CPU usage; battery level
and remaining/full energy in mWh, followed by signed power and time to the charge
target; fan RPM and duty percentage; and the main CPU, memory, NVMe, battery and
board temperatures. Multiple temperatures have a heading and indented lines.
Missing sensors are unavailable in the settings page. Batteries reporting only
charge are converted to mWh using nominal voltage.
Choose zero to three top CPU applications, displayed below CPU usage in descending
order. Zero disables process sampling; the previous checkbox migrates to zero or
one. Readable CPU counters are sampled while enabled. Processes are grouped by
application where identifiable, otherwise by executable. KDE's catalogue supplies
friendly names; ambiguous matches use executable names. Percentages use total
CPU capacity, matching the CPU line, rather than one core. Short-lived processes
between samples cannot be counted. No process history is written to disk.
Charging estimates to a reduced limit use the current charging rate and are
marked approximate. Hover choices use the shared Save and Apply / Undo controls.
Plasma applies its own hover highlight to tray icons; the app does not patch
the system tray to suppress that effect.
@@ -1,6 +1,6 @@
Name: framework-laptop-tools
Version: 0.1.0
Release: 10%{?dist}
Release: 15%{?dist}
Summary: Hardware controls and monitoring for Framework Laptop 13 Pro
License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools
@@ -11,6 +11,7 @@ BuildRequires: gcc-c++
BuildRequires: extra-cmake-modules
BuildRequires: qt6-qtbase-devel
BuildRequires: kf6-kauth-devel
BuildRequires: kf6-kservice-devel
BuildRequires: systemd-devel
BuildRequires: systemd-rpm-macros
Requires: polkit
@@ -55,6 +56,21 @@ install -Dpm 0644 %{SOURCE1} %{buildroot}%{_licensedir}/%{name}/LICENSE
%{_unitdir}/framework-laptop-tools-fan-resume.service
%{_unitdir}/framework-laptop-tools-cpu.service
%changelog
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-15
- Expand tray hover details and allow zero to three top CPU applications
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-14
- Nest tray graph options and add configurable hover telemetry with busiest app
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-13
- Use time-weighted average graph lines with a faint average-to-maximum band
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-12
- Add shared Monitor history controls and retain 24 hours of all sensor readings
- Limit tray history choices to short spans from 10 seconds to 5 minutes
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-11
- Add a configurable history span for tray graphs
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-10
- Reduce chart unit spacing without overlapping time labels
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-9
+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;
@@ -42,10 +42,14 @@ private Q_SLOTS:
QVERIFY(!batteryRate({{"watts", 12.3}, {"state", "Unknown"}}));
put(battery + "charge_full", "4500000"); put(battery + "charge_full_design", "5000000");
put(battery + "voltage_min_design", "16000000");
put(battery + "charge_now", "3000000");
QCOMPARE(batteryStatus(root.path())["remainingMWh"].toDouble(), 48000.);
QCOMPARE(batteryStatus(root.path())["fullMWh"].toDouble(), 72000.);
QCOMPARE(batteryStatus(root.path())["health"].toDouble(), 90.);
QVERIFY(batteryStatus(root.path())["capacityEstimated"].toBool());
put(battery + "energy_full", "75000000"); put(battery + "energy_full_design", "75000000");
put(battery + "energy_now", "56250000");
QCOMPARE(batteryStatus(root.path())["remainingMWh"].toDouble(), 56250.);
QCOMPARE(batteryStatus(root.path())["fullMWh"].toDouble(), 75000.);
QCOMPARE(batteryStatus(root.path())["health"].toDouble(), 100.);
QVERIFY(!batteryStatus(root.path()).contains("capacityEstimated"));
+295 -3
View File
@@ -15,10 +15,222 @@
#include "traypage.h"
#include "colorbutton.h"
#include <QMessageBox>
#include <QSlider>
#include "graphdata.h"
#include "tooltip.h"
#include "processusage.h"
#include <QFile>
#include <QDir>
class WindowTest : public QObject {
Q_OBJECT
private Q_SLOTS:
void hoverFormatting() {
const QVector<Sensor> sensors{{"cros_ec/peci-temp", "CPU", {}, "°C"}, {"spd5118/temp1", "Memory", {}, "°C"},
{"nvme/Composite", "NVMe", {}, "°C"}};
const auto temperatures = tooltipSensors(sensors);
QCOMPARE(temperatures[0].id, sensors[0].id); QVERIFY(temperatures[3].id.isEmpty());
const QMap<QString, double> readings{{"cpu-usage", 12.3}, {"fan", 2500}, {"fan-duty", 40}, {sensors[0].id, 47.}, {sensors[1].id, 38.}, {sensors[2].id, 41.}};
QVariantMap settings{{"tray/hover/topApp", true}};
auto text = trayTooltip(settings, readings, temperatures, "Battery 80%", {"Firefox: 5.4 %"});
QCOMPARE(text, QString("CPU usage: 12.3 %\n\u2003Firefox: 5.4 %\nBattery 80%\nFan speed: 2500 RPM (40 %)\nTemperatures:\n\u2003CPU 47°C\n\u2003RAM 38°C\n\u2003NVMe 41°C"));
const QStringList apps{"Firefox: 5.4 %", "Dolphin: 2.0 %", "Konsole: 1.0 %"};
for (int count = 0; count <= 3; ++count) {
settings["tray/hover/topApps"] = count;
const auto popup = trayTooltip(settings, readings, temperatures, {}, apps);
for (int i = 0; i < 3; ++i) QCOMPARE(popup.contains(apps[i]), i < count);
}
settings["tray/hover/cpu"] = false; settings["tray/hover/battery"] = false; settings["tray/hover/fan"] = false;
settings["tray/hover/temperature/memory"] = false; settings["tray/hover/temperature/nvme"] = false;
QCOMPARE(trayTooltip(settings, readings, temperatures, {}, apps), QString("CPU 47°C"));
QCOMPARE(trayTooltip(settings, {}, temperatures, {}, {}), QString("CPU —°C"));
settings["tray/hover/temperature/cpu"] = false;
QVERIFY(trayTooltip(settings, readings, temperatures, {}, {}).isEmpty());
QVariantMap battery{{"state", "Discharging"}, {"capacity", 75}, {"watts", 12.5}, {"remainingMWh", 56250}, {"fullMWh", 75000}};
QCOMPARE(batteryTooltip(battery, 80, {{"TimeToEmpty", 5400}}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003-12.5 W · 1 h 30 min to 0%"));
battery["state"] = "Charging";
QCOMPARE(batteryTooltip(battery, 100, {{"TimeToFull", 3600}}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003+12.5 W · 1 h 0 min to 100%"));
battery["charge_full"] = 10000; battery["charge_now"] = 7500; battery["current_now"] = 1000;
QCOMPARE(batteryTooltip(battery, 80, {}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003+12.5 W · ≈0 h 30 min to 80%"));
battery["current_now"] = 0;
QVERIFY(batteryTooltip(battery, 80, {}).contains("Time remaining unavailable"));
battery["capacity"] = 80;
QVERIFY(batteryTooltip(battery, 80, {}).contains("80% limit reached"));
QVERIFY(batteryTooltip({}, 100, {}).contains("unavailable"));
}
void processCpuAccounting() {
QTemporaryDir proc;
const auto writeStat = [&](int pid, const QString &command, int ticks, int start) {
QVERIFY(QDir().mkpath(proc.filePath(QString::number(pid))));
QStringList fields; for (int i = 0; i < 20; ++i) fields << "0";
fields[0] = "R"; fields[11] = QString::number(ticks); fields[19] = QString::number(start);
QFile file(proc.filePath(QString::number(pid) + "/stat")); QVERIFY(file.open(QIODevice::WriteOnly));
file.write((QString::number(pid) + " (" + command + ") " + fields.join(' ')).toUtf8());
};
ProcessUsage usage;
writeStat(1, "Test) App", 100, 1); writeStat(2, "Test) App", 50, 2); writeStat(3, "Other", 100, 3);
QVERIFY(usage.sample({}, proc.path()).isEmpty());
writeStat(1, "Test) App", 110, 1); writeStat(2, "Test) App", 70, 2); writeStat(3, "Other", 125, 3);
auto top = usage.sample(100, proc.path()); QCOMPARE(top.size(), 2);
QCOMPARE(top[0].name, QString("Test) App")); QCOMPARE(top[0].percent, 30.); // Sum matching app processes, not just the busiest PID.
QCOMPARE(top[1].name, QString("Other")); QCOMPARE(top[1].percent, 25.);
writeStat(1, "Replacement", 5000, 9); // Reused PID must not look like a CPU spike.
writeStat(3, "Other", 130, 3);
top = usage.sample(100, proc.path()); QVERIFY(!top.isEmpty());
QCOMPARE(top[0].name, QString("Other")); QCOMPARE(top[0].percent, 5.);
QVERIFY(QFile::remove(proc.filePath("2/stat"))); // Exit/unreadable stat is an expected race.
top = usage.sample(100, proc.path()); QVERIFY(!top.isEmpty()); QCOMPARE(top[0].percent, 0.);
// More than three apps: limit the list and rank by usage, with stable name ordering on ties.
writeStat(4, "Fourth", 10, 4); writeStat(5, "Fifth", 10, 5);
usage.sample(100, proc.path());
writeStat(1, "Replacement", 5020, 9); writeStat(3, "Other", 170, 3);
writeStat(4, "Fourth", 20, 4); writeStat(5, "Fifth", 30, 5);
top = usage.sample(100, proc.path()); QCOMPARE(top.size(), 3);
QCOMPARE(top[0].name, QString("Other")); QCOMPARE(top[1].name, QString("Fifth")); QCOMPARE(top[2].name, QString("Replacement"));
usage.reset(); QVERIFY(usage.sample(100, proc.path()).isEmpty());
}
void trayOptionHierarchy() {
QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat);
TrayPage page(settings, {{"cpu-usage", "CPU", {}, "%"}, {"cros_ec/peci-temp", "CPU temperature", {}, "°C"}});
page.show();
auto *mode = page.findChild<QComboBox *>("trayDisplayMode");
auto *graph = page.findChild<QWidget *>("trayGraphOptions");
auto *overflow = page.findChild<QWidget *>("trayOverflowOptions");
auto *outside = page.findChild<QComboBox *>("trayOutside");
QVERIFY(graph->isHidden());
mode->setCurrentIndex(mode->findData("graph")); QVERIFY(graph->isVisible()); QVERIFY(overflow->isVisible());
outside->setCurrentIndex(outside->findData("hide")); QVERIFY(overflow->isHidden());
outside->setCurrentIndex(outside->findData("clamp")); QVERIFY(overflow->isVisible());
auto *cpu = page.findChild<QCheckBox *>("hover/cpu");
auto *app = page.findChild<QSpinBox *>("hover/topApps");
QCOMPARE(app->minimum(), 0); QCOMPARE(app->maximum(), 3);
const auto saved = page.draft();
app->setValue(3); cpu->setChecked(false); QVERIFY(!app->isEnabled());
QCOMPARE(app->value(), 3); // Retain the choice when its parent is disabled.
QVERIFY(!page.findChild<QCheckBox *>("hover/temperature/memory")->isEnabled());
page.load(saved); QVERIFY(cpu->isChecked()); QVERIFY(app->isEnabled()); QCOMPARE(app->value(), 0);
mode->setCurrentIndex(mode->findData("number")); QVERIFY(graph->isHidden());
QVERIFY(cpu->isVisible()); QVERIFY(settings.allKeys().isEmpty()); // Staged until Save and Apply.
settings.setValue("tray/hover/topApp", true);
TrayPage migrated(settings, {{"cpu-usage", "CPU", {}, "%"}});
QCOMPARE(migrated.findChild<QSpinBox *>("hover/topApps")->value(), 1);
QVERIFY(!migrated.draft().contains("tray/hover/topApp"));
QCOMPARE(tooltipAppCount({{"tray/hover/topApps", 3}, {"tray/hover/topApp", false}}), 3);
}
void liveProcessSampling() {
CpuUsage cpu; ProcessUsage processes;
cpu.sample(readText("/proc/stat"));
QVERIFY(processes.sample(cpu.totalDelta()).isEmpty());
QTest::qWait(200);
QVERIFY(cpu.sample(readText("/proc/stat")));
const auto apps = processes.sample(cpu.totalDelta());
QVERIFY(!apps.isEmpty()); QVERIFY(apps.size() <= 3);
double previous = 100;
for (const auto &app : apps) {
QVERIFY(!app.name.isEmpty()); QVERIFY(std::isfinite(app.percent) && app.percent >= 0 && app.percent <= previous);
previous = app.percent;
}
}
void timeWeightedColumns() {
const auto connected = [](const QPointF &, const QPointF &) { return true; };
const QVector<QPointF> points{{0, 0}, {2, 10}, {10, 10}};
auto columns = timeAverages(points.begin(), points.end(), 0, 10, 2, connected);
QCOMPARE(columns.size(), 2);
QCOMPARE(columns[0].mean, 8.); QCOMPARE(columns[0].maximum, 10.);
QCOMPARE(columns[1].mean, 10.);
columns = timeAverages(points.begin(), points.end(), 1, 3, 1, connected);
QCOMPARE(columns[0].mean, 8.75); // Segment clipped and weighted at both pixel edges.
columns = timeAverages(points.begin(), points.end(), 0, 10, 7, connected);
double integral = 0;
for (const auto &column : columns) integral += column.mean * (column.end - column.begin);
QVERIFY(std::abs(integral - 90) < 1e-10); // Fractional boundaries preserve the total area.
columns = timeAverages(points.begin(), points.end(), -10, 20, 1, connected);
QCOMPARE(columns[0].mean, 9.); // Missing history contributes no artificial zeroes.
QCOMPARE(columns[0].begin, 0.); QCOMPARE(columns[0].end, 10.);
const QVector<QPointF> ramp{{0, -10}, {10, 10}};
columns = timeAverages(ramp.begin(), ramp.end(), 0, 10, 100, connected);
QCOMPARE(columns.size(), 100);
for (const auto &column : columns) {
QVERIFY(std::abs(column.mean - (column.begin + column.end - 10)) < 1e-10);
QVERIFY(std::abs(column.maximum - (column.end * 2 - 10)) < 1e-10);
}
const QVector<QPointF> gaps{{0, 0}, {2, 10}, {3, NAN}, {4, 10}, {6, 0}};
columns = timeAverages(gaps.begin(), gaps.end(), 0, 6, 1, connected);
QCOMPARE(columns.size(), 2); QVERIFY(columns[1].startsRun);
QCOMPARE(columns[0].mean, 5.); QCOMPARE(columns[1].mean, 5.);
const auto line = averageLine(columns);
QVERIFY(std::isnan(line[3].y())); // Never bridge a gap, even inside one pixel.
columns = timeAverages(points.begin(), points.end(), 0, 10, 1,
[](const QPointF &, const QPointF &b) { return b.x() < 10; });
QCOMPARE(columns.size(), 1); QCOMPARE(columns[0].end, 2.); // Explicit suspend break.
}
void dayHistoryAndStretch() {
Chart chart("MHz"); chart.addSeries("hidden");
const qint64 start = 100000000;
for (int i = 0; i <= 7200; ++i) chart.sample({{"hidden", double(i)}}, 500, start + i * 500);
QCOMPARE(chart.history("hidden").size(), 7201); // Not capped at 600, even when hidden.
chart.setSelected("hidden", true);
QVERIFY(chart.readingAt(start + 500).contains("hidden: 1 MHz"));
const auto end = double(start + 3600000);
chart.setHistoryWindow(historyRetentionMs, true);
QCOMPARE(chart.timeRange(), qMakePair(double(start), end));
chart.setHistoryWindow(historyRetentionMs, false);
QCOMPARE(chart.timeRange(), qMakePair(end - historyRetentionMs, end));
chart.setHistoryWindow(300000, true);
QCOMPARE(chart.timeRange(), qMakePair(end - 300000, end));
QCOMPARE(chart.history("hidden", end - 10000).size(), 22); // Includes the clipping boundary.
chart.sample({{"hidden", 1}}, 500, start + historyRetentionMs + 1000);
const auto retained = chart.history("hidden");
QCOMPARE(retained.first().x(), double(start + 1000));
chart.setHistoryWindow(historyRetentionMs, true);
QCOMPARE(chart.timeRange().first, double(start + 1000));
}
void monitorHistorySettings() {
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
{
Window window;
auto *slider = window.findChild<QSlider *>("monitorHistory"); QVERIFY(slider);
auto *stretch = window.findChild<QCheckBox *>("monitorStretch"); QVERIFY(stretch);
QCOMPARE(slider->value(), 1000); QVERIFY(stretch->isChecked());
slider->setValue(0); stretch->setChecked(false);
for (auto *chart : window.findChildren<Chart *>())
QCOMPARE(chart->timeRange().second - chart->timeRange().first, 300000.);
slider->setValue(500);
QSettings settings("fedora-tools", "framework-laptop-tools");
const int minutes = settings.value("monitor/historyMinutes").toInt();
QVERIFY(minutes >= 84 && minutes <= 86); // Geometric, not arithmetic, midpoint.
}
Window restored;
QCOMPARE(restored.findChild<QSlider *>("monitorHistory")->value(), 500);
QVERIFY(!restored.findChild<QCheckBox *>("monitorStretch")->isChecked());
QSettings settings("fedora-tools", "framework-laptop-tools");
settings.setValue("tray/historyMs", 86400000);
TrayPage tray(settings, {{"cpu-usage", "CPU usage", {}, "%"}});
QCOMPARE(tray.iconStyle().historyMs, qint64(60000)); // Retired choices use the new default.
}
void denseHistorySoftensPeaks() {
Chart chart("%"); chart.resize(600, 200); chart.addSeries("sensor"); chart.setSelected("sensor", true);
const qint64 start = 100000000;
// A half-second spike is faint, not an opaque excursion of the mean line.
for (int i = 0; i <= 172800; ++i)
chart.sample({{"sensor", i == 43200 ? 100. : 10.}}, 500, start + i * 500);
QCOMPARE(chart.history("sensor").size(), 172801);
QVERIFY(chart.readingAt(start + 43200 * 500).contains("sensor: 100 %"));
const auto image = chart.grab().toImage();
const auto shot = qEnvironmentVariable("FRAMEWORK_TOOLS_AVERAGE_SCREENSHOT");
if (!shot.isEmpty()) QVERIFY(image.save(shot));
bool peak = false;
for (int y = 15; y < 60; ++y) for (int x = 100; x < 550; ++x) {
const auto colour = image.pixelColor(x, y);
QVERIFY(colour != chart.color("sensor"));
peak |= colour.blue() > colour.red() + 10 && colour.green() > colour.red() + 5;
}
QVERIFY(peak);
chart.setHistoryWindow(300000, true);
const auto recent = chart.grab().toImage();
for (int y = 15; y < 60; ++y) for (int x = 100; x < 550; ++x)
QVERIFY(recent.pixelColor(x, y) != chart.color("sensor"));
}
void logicalTicks() {
const auto freq = AxisTicks::covering(0, 4681, 4);
QCOMPARE(freq.minimum, 0.); QCOMPARE(freq.maximum, 6000.); QCOMPARE(freq.step, 2000.);
@@ -50,7 +262,8 @@ private Q_SLOTS:
chart.setSelected("rate", false);
QVERIFY(!chart.readingAt(112000).contains("Battery rate"));
QVERIFY(chart.readingAt(200000).contains("Charge level: —"));
for (int i = 0; i < 601; ++i) chart.sample({}, 30000, 200000 + i * 30000);
chart.sample({}, 30000, historyRetentionMs + 200000);
chart.sample({}, 30000, historyRetentionMs + 230000);
QVERIFY(!chart.readingAt(106000).contains("Asleep")); // Annotations expire with samples.
}
void clickableLegend() {
@@ -86,8 +299,9 @@ private Q_SLOTS:
}
void trayBoundsAndAppearance() {
TrayStyle style; style.borderColor = Qt::white; style.backgroundColor = Qt::black;
style.historyMs = 10;
style.lineColor = Qt::green; style.fillColor = Qt::blue; style.outsideColor = Qt::red; style.fill = false;
auto render = [&] { return telemetryIcon(true, {{0, 120}, {10, 120}}, "%", style).pixmap(64, 64).toImage(); };
auto render = [&] { return telemetryIcon(true, {{0, 120}, {10, 120}}, "%", style, 10).pixmap(64, 64).toImage(); };
auto image = render();
QCOMPARE(image.pixelColor(32, 1), QColor(Qt::white));
QCOMPARE(image.pixelColor(32, 3), QColor(Qt::red));
@@ -97,10 +311,55 @@ private Q_SLOTS:
style.clamp = true; style.overflowColor = false; image = render(); QCOMPARE(image.pixelColor(32, 1), QColor(Qt::green));
QVERIFY(trayPlotRect(true).width() < trayPlotRect(false).width());
style.fill = true;
image = telemetryIcon(true, {{0, 50}, {10, 50}}, "%", style).pixmap(64, 64).toImage();
image = telemetryIcon(true, {{0, 50}, {10, 50}}, "%", style, 10).pixmap(64, 64).toImage();
QCOMPARE(image.pixelColor(32, 50), QColor(Qt::blue));
QCOMPARE(image.pixelColor(32, 10).alpha(), 0);
}
void trayHistoryWindow() {
TrayStyle style; style.borderColor = Qt::white; style.backgroundColor = Qt::black;
style.lineColor = Qt::green; style.fillColor = Qt::blue; style.fill = false;
style.historyMs = 60000;
const QVector<QPointF> points{{0, 50}, {60000, 50}, {90000, 50}};
auto render = [&](qint64 now) { return telemetryIcon(true, points, "%", style, now).pixmap(64, 64).toImage(); };
auto image = render(120000);
QCOMPARE(image.pixelColor(10, 32), QColor(Qt::green)); // Segment crossing the left boundary is clipped.
QCOMPARE(image.pixelColor(50, 32), QColor(Qt::black)); // No extrapolation from the latest sample to now.
QCOMPARE(image.pixelColor(1, 32), QColor(Qt::white)); // Clipping preserves the border.
image = render(240000);
QCOMPARE(image.pixelColor(32, 32), QColor(Qt::black)); // All readings have expired from the view.
style.historyMs = 120000;
image = render(90000);
QCOMPARE(image.pixelColor(5, 32), QColor(Qt::black)); // Unavailable older history stays blank.
QCOMPARE(image.pixelColor(32, 32), QColor(Qt::green));
style.fill = true;
image = telemetryIcon(true, {{0, 50}, {30000, 50}, {60000, NAN}, {90000, 50}, {120000, 50}}, "%", style, 120000).pixmap(64, 64).toImage();
QCOMPARE(image.pixelColor(10, 50), QColor(Qt::blue));
QCOMPARE(image.pixelColor(32, 50), QColor(Qt::black)); // Sleep/data gaps remain gaps.
const auto number = telemetryIcon(false, points, "%", style, 120000).pixmap(64, 64).toImage();
style.historyMs = 10000;
QCOMPARE(telemetryIcon(false, points, "%", style, 120000).pixmap(64, 64).toImage(), number);
QCOMPARE(image.pixelColor(60, 32), QColor(Qt::green));
}
void traySoftensPeaks() {
TrayStyle style; style.border = false; style.backgroundColor = Qt::black;
style.lineColor = Qt::green; style.fill = false; style.historyMs = 300000;
QVector<QPointF> points;
for (int i = 0; i <= 600; ++i) points.append({double(i * 500), i == 300 ? 100. : 10.});
const auto image = telemetryIcon(true, points, "%", style, 300000).pixmap(64, 64).toImage();
const auto shot = qEnvironmentVariable("FRAMEWORK_TOOLS_AVERAGE_SCREENSHOT");
if (!shot.isEmpty()) QVERIFY(image.save(shot + ".tray.png"));
bool band = false;
for (int y = 4; y < 30; ++y) for (int x = 20; x < 45; ++x) {
const auto colour = image.pixelColor(x, y);
QVERIFY(colour != QColor(Qt::green));
band |= colour.green() > 0 && colour.green() < 100;
}
QVERIFY(band);
bool mean = false;
for (int y = 50; y < 62; ++y) for (int x = 5; x < 59; ++x)
mean |= image.pixelColor(x, y) == QColor(Qt::green);
QVERIFY(mean);
}
void sharedHistoryAndTrayScales() {
Chart chart("MHz"); chart.addSeries("cpu"); chart.addSeries("gpu");
chart.sample({{"cpu", 1000}, {"gpu", 500}}, 1000, 1000);
@@ -114,6 +373,13 @@ private Q_SLOTS:
QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat);
TrayPage page(settings, {{"cpu", "CPU", {}, "MHz"}, {"temp", "CPU temperature", {}, "°C"}, {"rate", "Battery rate", {}, "W"}});
const auto original = page.draft();
auto *history = page.findChild<QComboBox *>("trayHistory"); QVERIFY(history);
QCOMPARE(page.iconStyle().historyMs, qint64(60000));
QCOMPARE(history->count(), 7);
const QList<int> spans{10000, 15000, 20000, 30000, 60000, 120000, 300000};
for (int i = 0; i < spans.size(); ++i) QCOMPARE(history->itemData(i).toInt(), spans[i]);
history->setCurrentIndex(history->findData(300000));
QCOMPARE(page.iconStyle().historyMs, qint64(300000));
auto *mode = page.findChild<QComboBox *>("trayDisplayMode"); mode->setCurrentIndex(1);
auto *metric = page.findChild<QComboBox *>("trayMetric");
auto *minimum = page.findChild<QDoubleSpinBox *>("trayMinimum"); auto *maximum = page.findChild<QDoubleSpinBox *>("trayMaximum");
@@ -130,6 +396,7 @@ private Q_SLOTS:
if (!shot.isEmpty()) { page.resize(700, 780); page.show(); QTest::qWait(10); QVERIFY(page.grab().save(shot)); }
QVERIFY(settings.allKeys().isEmpty()); // Editing never writes persistent settings.
page.load(original);
QCOMPARE(history->currentData().toInt(), 60000);
QCOMPARE(mode->currentIndex(), 0); QCOMPARE(metric->currentIndex(), 0);
QVERIFY(!settings.contains("tray/scales/rate/minimum"));
}
@@ -310,24 +577,49 @@ private Q_SLOTS:
QTRY_VERIFY(save->isEnabled()); QVERIFY(bar->isHidden());
auto *fast = window.findChild<QComboBox *>("fastInterval");
auto *mode = window.findChild<QComboBox *>("trayDisplayMode");
auto *history = window.findChild<QComboBox *>("trayHistory");
auto *autostart = window.findChild<QCheckBox *>("loginAutostart");
auto *hoverCpu = window.findChild<QCheckBox *>("hover/cpu");
auto *hoverApps = window.findChild<QSpinBox *>("hover/topApps");
auto *trayIcon = window.findChild<QSystemTrayIcon *>();
const QString path = config.path() + "/autostart/se.ajpanton.framework-laptop-tools.desktop";
fast->setCurrentIndex(fast->findData(4000));
QVERIFY(!bar->isHidden()); tabs->setCurrentIndex(5); mode->setCurrentIndex(1);
history->setCurrentIndex(history->findData(300000));
autostart->setChecked(true);
hoverCpu->setChecked(false);
hoverApps->setValue(2);
QVERIFY(trayIcon->toolTip().startsWith("CPU ")); // Unsaved edits do not alter the live tooltip.
QCOMPARE(settings.value("sampling/fast").toInt(), 1000);
QVERIFY(!settings.contains("tray/mode")); QVERIFY(!QFile::exists(path));
QVERIFY(!settings.contains("tray/historyMs"));
tabs->setCurrentIndex(0); QVERIFY(!bar->isHidden());
QTRY_VERIFY(undo->isEnabled()); undo->click();
QVERIFY(bar->isHidden()); QCOMPARE(fast->currentData().toInt(), 1000); QCOMPARE(mode->currentIndex(), 0);
QVERIFY(!autostart->isChecked()); QVERIFY(!QFile::exists(path));
QVERIFY(hoverCpu->isChecked());
QCOMPARE(hoverApps->value(), 0);
QCOMPARE(history->currentData().toInt(), 60000);
fast->setCurrentIndex(fast->findData(2000)); mode->setCurrentIndex(2); autostart->setChecked(true);
history->setCurrentIndex(history->findData(60000));
hoverCpu->setChecked(false);
hoverApps->setValue(3);
QTRY_VERIFY(save->isEnabled()); save->click();
QVERIFY(bar->isHidden()); QCOMPARE(settings.value("sampling/fast").toInt(), 2000);
QCOMPARE(settings.value("tray/mode").toString(), QString("number")); QVERIFY(QFile::exists(path));
QCOMPARE(settings.value("tray/historyMs").toInt(), 60000);
QVERIFY(!settings.value("tray/hover/cpu").toBool());
QCOMPARE(settings.value("tray/hover/topApps").toInt(), 3);
QVERIFY(!trayIcon->toolTip().startsWith("CPU "));
hoverCpu->setChecked(true);
hoverApps->setValue(1);
history->setCurrentIndex(history->findData(30000));
fast->setCurrentIndex(fast->findData(500)); mode->setCurrentIndex(1);
QTRY_VERIFY(undo->isEnabled()); undo->click();
QCOMPARE(fast->currentData().toInt(), 2000); QCOMPARE(mode->currentIndex(), 2); QVERIFY(bar->isHidden());
QCOMPARE(history->currentData().toInt(), 60000);
QVERIFY(!hoverCpu->isChecked());
QCOMPARE(hoverApps->value(), 3);
// Hover is a timestamp shared by every graph, not the same screen coordinate.
const auto charts = window.findChildren<Chart *>(); QCOMPARE(charts.size(), 4);
const qint64 time = QDateTime::currentMSecsSinceEpoch() - 1000;