Refine Framework monitoring history and configurable tray telemetry
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user