From b8cb9dbddb6a63383f687fe0cb4112e13e18e21c Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Fri, 11 Sep 2026 20:47:09 +0000 Subject: [PATCH] Refine Framework monitoring history and configurable tray telemetry --- framework-laptop-tools/CMakeLists.txt | 7 +- framework-laptop-tools/README.md | 34 +- .../framework-laptop-tools.spec | 18 +- framework-laptop-tools/src/chart.cpp | 69 ++-- framework-laptop-tools/src/chart.h | 9 +- framework-laptop-tools/src/graphdata.h | 65 ++++ framework-laptop-tools/src/hardware.cpp | 3 + framework-laptop-tools/src/history.h | 23 ++ framework-laptop-tools/src/processusage.cpp | 84 +++++ framework-laptop-tools/src/processusage.h | 20 ++ framework-laptop-tools/src/tooltip.cpp | 85 +++++ framework-laptop-tools/src/tooltip.h | 11 + framework-laptop-tools/src/tray.cpp | 36 ++- framework-laptop-tools/src/tray.h | 9 +- framework-laptop-tools/src/traypage.cpp | 70 +++- framework-laptop-tools/src/traypage.h | 2 +- framework-laptop-tools/src/window.cpp | 78 +++-- framework-laptop-tools/src/window.h | 5 +- .../tests/test-hardware.cpp | 4 + framework-laptop-tools/tests/test-window.cpp | 298 +++++++++++++++++- 20 files changed, 847 insertions(+), 83 deletions(-) create mode 100644 framework-laptop-tools/src/graphdata.h create mode 100644 framework-laptop-tools/src/history.h create mode 100644 framework-laptop-tools/src/processusage.cpp create mode 100644 framework-laptop-tools/src/processusage.h create mode 100644 framework-laptop-tools/src/tooltip.cpp create mode 100644 framework-laptop-tools/src/tooltip.h diff --git a/framework-laptop-tools/CMakeLists.txt b/framework-laptop-tools/CMakeLists.txt index 17745d6..552de89 100644 --- a/framework-laptop-tools/CMakeLists.txt +++ b/framework-laptop-tools/CMakeLists.txt @@ -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() diff --git a/framework-laptop-tools/README.md b/framework-laptop-tools/README.md index 1bfc55f..0a2b70c 100644 --- a/framework-laptop-tools/README.md +++ b/framework-laptop-tools/README.md @@ -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 −75–75 W), while percentages use 0–100. +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. diff --git a/framework-laptop-tools/framework-laptop-tools.spec b/framework-laptop-tools/framework-laptop-tools.spec index 0ab86ca..0963ad6 100644 --- a/framework-laptop-tools/framework-laptop-tools.spec +++ b/framework-laptop-tools/framework-laptop-tools.spec @@ -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 diff --git a/framework-laptop-tools/src/chart.cpp b/framework-laptop-tools/src/chart.cpp index 8d27e9f..80ccf68 100644 --- a/framework-laptop-tools/src/chart.cpp +++ b/framework-laptop-tools/src/chart.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT #include "chart.h" +#include "graphdata.h" #include #include #include @@ -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 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 Chart::history(const QString &id, double since) const { QVector 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 &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::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::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 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); } diff --git a/framework-laptop-tools/src/chart.h b/framework-laptop-tools/src/chart.h index d1422c5..b05d78c 100644 --- a/framework-laptop-tools/src/chart.h +++ b/framework-laptop-tools/src/chart.h @@ -5,6 +5,7 @@ #include #include #include +#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 history(const QString &id) const; + QVector history(const QString &id, double since = 0) const; + void setHistoryWindow(qint64 spanMs, bool stretch); + QPair timeRange() const; void setHoverTime(qint64 time) { m_hoverTime = time < 0 ? std::nullopt : std::optional(time); update(); } std::optional 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 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 m_bands; QString m_unit; int m_interval = 1000; + qint64 m_historyMs = historyRetentionMs; + bool m_stretch = true; qint64 m_sampleTime = QDateTime::currentMSecsSinceEpoch(); std::optional m_hoverTime; }; diff --git a/framework-laptop-tools/src/graphdata.h b/framework-laptop-tools/src/graphdata.h new file mode 100644 index 0000000..c881f81 --- /dev/null +++ b/framework-laptop-tools/src/graphdata.h @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include +#include + +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 +QVector timeAverages(Iterator begin, Iterator end, double first, + double last, int width, Connected connected) +{ + QVector 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 averageLine(const QVector &columns) +{ + QVector 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; +} diff --git a/framework-laptop-tools/src/hardware.cpp b/framework-laptop-tools/src/hardware.cpp index 94241c0..0e3a513 100644 --- a/framework-laptop-tools/src/hardware.cpp +++ b/framework-laptop-tools/src/hardware.cpp @@ -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 {}; diff --git a/framework-laptop-tools/src/history.h b/framework-laptop-tools/src/history.h new file mode 100644 index 0000000..aa879b9 --- /dev/null +++ b/framework-laptop-tools/src/history.h @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include + +inline constexpr qint64 historyRetentionMs = 24 * 60 * 60 * 1000; +using Samples = std::deque; + +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; +} diff --git a/framework-laptop-tools/src/processusage.cpp b/framework-laptop-tools/src/processusage.cpp new file mode 100644 index 0000000..2473110 --- /dev/null +++ b/framework-laptop-tools/src/processusage.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +#include "processusage.h" +#include "hardware.h" +#include +#include +#include +#include +#include +#include +#include +#include + +ProcessUsage::App ProcessUsage::application(const QString &directory, const QString &command) +{ + if (!m_loadedApps) { + QSet 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 ProcessUsage::sample(std::optional totalDelta, const QString &proc) +{ + QMap current; + QMap usage; + QMap 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 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; +} diff --git a/framework-laptop-tools/src/processusage.h b/framework-laptop-tools/src/processusage.h new file mode 100644 index 0000000..4383317 --- /dev/null +++ b/framework-laptop-tools/src/processusage.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include +#include + +struct BusyApp { QString name; double percent; }; +class ProcessUsage { +public: + QVector sample(std::optional 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 m_previous; + QMap m_apps; + bool m_loadedApps = false; +}; diff --git a/framework-laptop-tools/src/tooltip.cpp b/framework-laptop-tools/src/tooltip.cpp new file mode 100644 index 0000000..41b791b --- /dev/null +++ b/framework-laptop-tools/src/tooltip.cpp @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +#include "tooltip.h" +#include +#include + +QVector tooltipSensors(const QVector &sensors) +{ + QVector 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 &values, + const QVector &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'); +} diff --git a/framework-laptop-tools/src/tooltip.h b/framework-laptop-tools/src/tooltip.h new file mode 100644 index 0000000..03a736e --- /dev/null +++ b/framework-laptop-tools/src/tooltip.h @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include "hardware.h" + +struct TooltipSensor { QString key, name, id; }; +QVector tooltipSensors(const QVector &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 &values, + const QVector &temperatures, const QString &battery, const QStringList &topApps); diff --git a/framework-laptop-tools/src/tray.cpp b/framework-laptop-tools/src/tray.cpp index 06ede73..e964ff8 100644 --- a/framework-laptop-tools/src/tray.cpp +++ b/framework-laptop-tools/src/tray.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT #include "tray.h" +#include "graphdata.h" #include #include #include @@ -7,6 +8,7 @@ std::optional 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 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 &values, const QString &unit, const TrayStyle &style) +QIcon telemetryIcon(bool graph, const QVector &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 &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 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 &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); diff --git a/framework-laptop-tools/src/tray.h b/framework-laptop-tools/src/tray.h index f96b9d0..994c74b 100644 --- a/framework-laptop-tools/src/tray.h +++ b/framework-laptop-tools/src/tray.h @@ -3,20 +3,25 @@ #include #include #include +#include #include class CpuUsage { public: std::optional sample(const QString &procStat); - void reset() { m_previous.reset(); } + void reset() { m_previous.reset(); m_delta.reset(); } + std::optional totalDelta() const { return m_delta; } private: struct Counters { quint64 total, idle; }; std::optional m_previous; + std::optional 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 &values, const QString &unit, const TrayStyle &style); +QIcon telemetryIcon(bool graph, const QVector &values, const QString &unit, const TrayStyle &style, + qint64 now = QDateTime::currentMSecsSinceEpoch()); diff --git a/framework-laptop-tools/src/traypage.cpp b/framework-laptop-tools/src/traypage.cpp index 094d602..4ff556a 100644 --- a/framework-laptop-tools/src/traypage.cpp +++ b/framework-laptop-tools/src/traypage.cpp @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT #include "traypage.h" #include "colorbutton.h" +#include "tooltip.h" +#include +#include #include #include #include @@ -19,7 +22,11 @@ TrayPage::TrayPage(QSettings &settings, const QVector &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 &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 &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 &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)); diff --git a/framework-laptop-tools/src/traypage.h b/framework-laptop-tools/src/traypage.h index 167892d..99f7134 100644 --- a/framework-laptop-tools/src/traypage.h +++ b/framework-laptop-tools/src/traypage.h @@ -28,7 +28,7 @@ private: QList> m_reload; bool m_loading = false; QVector 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; diff --git a/framework-laptop-tools/src/window.cpp b/framework-laptop-tools/src/window.cpp index ddb6450..013ecf8 100644 --- a/framework-laptop-tools/src/window.cpp +++ b/framework-laptop-tools/src/window.cpp @@ -2,6 +2,7 @@ #include "window.h" #include "legend.h" #include "fan.h" +#include "tooltip.h" #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include #include 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 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 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 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 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 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) diff --git a/framework-laptop-tools/src/window.h b/framework-laptop-tools/src/window.h index 32caba2..b3219c9 100644 --- a/framework-laptop-tools/src/window.h +++ b/framework-laptop-tools/src/window.h @@ -4,6 +4,7 @@ #include "cpupage.h" #include "tray.h" #include "traypage.h" +#include "processusage.h" #include #include #include @@ -49,7 +50,9 @@ private: QVector m_temperatures, m_frequencies; QMap m_values; CpuUsage m_cpuUsage; - QVector 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; diff --git a/framework-laptop-tools/tests/test-hardware.cpp b/framework-laptop-tools/tests/test-hardware.cpp index 3973e3c..f9224cb 100644 --- a/framework-laptop-tools/tests/test-hardware.cpp +++ b/framework-laptop-tools/tests/test-hardware.cpp @@ -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")); diff --git a/framework-laptop-tools/tests/test-window.cpp b/framework-laptop-tools/tests/test-window.cpp index 5db3905..85dc250 100644 --- a/framework-laptop-tools/tests/test-window.cpp +++ b/framework-laptop-tools/tests/test-window.cpp @@ -15,10 +15,222 @@ #include "traypage.h" #include "colorbutton.h" #include +#include +#include "graphdata.h" +#include "tooltip.h" +#include "processusage.h" +#include +#include class WindowTest : public QObject { Q_OBJECT private Q_SLOTS: + void hoverFormatting() { + const QVector 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 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("trayDisplayMode"); + auto *graph = page.findChild("trayGraphOptions"); + auto *overflow = page.findChild("trayOverflowOptions"); + auto *outside = page.findChild("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("hover/cpu"); + auto *app = page.findChild("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("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("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 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 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 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("monitorHistory"); QVERIFY(slider); + auto *stretch = window.findChild("monitorStretch"); QVERIFY(stretch); + QCOMPARE(slider->value(), 1000); QVERIFY(stretch->isChecked()); + slider->setValue(0); stretch->setChecked(false); + for (auto *chart : window.findChildren()) + 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("monitorHistory")->value(), 500); + QVERIFY(!restored.findChild("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 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 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("trayHistory"); QVERIFY(history); + QCOMPARE(page.iconStyle().historyMs, qint64(60000)); + QCOMPARE(history->count(), 7); + const QList 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("trayDisplayMode"); mode->setCurrentIndex(1); auto *metric = page.findChild("trayMetric"); auto *minimum = page.findChild("trayMinimum"); auto *maximum = page.findChild("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("fastInterval"); auto *mode = window.findChild("trayDisplayMode"); + auto *history = window.findChild("trayHistory"); auto *autostart = window.findChild("loginAutostart"); + auto *hoverCpu = window.findChild("hover/cpu"); + auto *hoverApps = window.findChild("hover/topApps"); + auto *trayIcon = window.findChild(); 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(); QCOMPARE(charts.size(), 4); const qint64 time = QDateTime::currentMSecsSinceEpoch() - 1000;