Publish tool cleanup and Framework monitoring refinements

This commit is contained in:
ajp_anton
2026-09-12 05:17:02 +00:00
parent db352083cf
commit 7f56368674
51 changed files with 1487 additions and 341 deletions
+2 -1
View File
@@ -15,10 +15,11 @@ include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Quick Test)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Quick)
find_package(KF6 6.0 REQUIRED COMPONENTS Auth Config CoreAddons I18n KCMUtils)
add_subdirectory(src)
if(BUILD_TESTING)
find_package(Qt6 6.8 REQUIRED COMPONENTS Test)
add_subdirectory(tests)
endif()
@@ -1,6 +1,6 @@
Name: fedora-tools-settings
Version: 0.1.0
Release: 12%{?dist}
Release: 13%{?dist}
Summary: Plasma System Settings module for Fedora Tools
License: MIT
@@ -74,6 +74,10 @@ done
%{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy
%changelog
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-13
- Share settings validation and report failed configuration writes
- Simplify ordering and source packaging
* Tue Sep 08 2026 fedora-tools contributors - 0.1.0-12
- Hide unsupported Framework hardware tools and flag incompatible local installs
+9 -9
View File
@@ -11,7 +11,6 @@
#include <KSharedConfig>
#include <QFile>
#include <QRegularExpression>
#include <QTimer>
#include <algorithm>
@@ -212,7 +211,10 @@ void FedoraToolsKcm::saveShortcutSettings(bool startWithFirst,
group.writeEntry("StartWithFirstWindow", startWithFirst);
group.writeEntry("InitialShiftOpensNewInstance", initialShiftOpensNew);
group.writeEntry("ShiftCyclesBackward", shiftCyclesBackward);
group.sync();
if (!group.sync()) {
setMessage(i18n("Could not save shortcut settings."), true);
return;
}
loadToolSettings();
setMessage(i18n("Shortcut settings were saved."));
}
@@ -222,7 +224,10 @@ void FedoraToolsKcm::resetShortcutSettings()
const auto config = KSharedConfig::openConfig(QStringLiteral("plasma-task-group-shortcutsrc"));
KConfigGroup group(config, QStringLiteral("Settings"));
group.deleteGroup();
group.sync();
if (!group.sync()) {
setMessage(i18n("Could not reset shortcut settings."), true);
return;
}
loadToolSettings();
setMessage(i18n("Shortcut settings were reset to their defaults."));
}
@@ -233,15 +238,10 @@ void FedoraToolsKcm::saveTouchpadSettings(int minimumAnchorAge,
int maximumTap,
const QString &outputEvent)
{
static const QRegularExpression eventName(QStringLiteral("^(BTN|KEY)_[A-Z0-9_]+$"));
if (m_busy) {
return;
}
if (minimumAnchorAge < 0 || minimumAnchorAge > 2000
|| minimumPause < 0 || minimumPause > 2000
|| minimumTap < 0 || minimumTap > maximumTap
|| maximumTap < 1 || maximumTap > 2000
|| !eventName.match(outputEvent).hasMatch()) {
if (!validTouchpadSettings(minimumAnchorAge, minimumPause, minimumTap, maximumTap, outputEvent)) {
setMessage(i18n("Enter valid timing values and an evdev BTN_* or KEY_* output event."), true);
return;
}
+7 -11
View File
@@ -8,7 +8,6 @@
#include <QFileInfo>
#include <QObject>
#include <QProcess>
#include <QRegularExpression>
#include <sys/stat.h>
@@ -192,17 +191,14 @@ public Q_SLOTS:
return KAuth::ActionReply::InvalidActionReply();
}
const int minimumAnchorAge = arguments.value(QStringLiteral("minimumAnchorAge")).toInt();
const int minimumPause = arguments.value(QStringLiteral("minimumPause")).toInt();
const int minimumTap = arguments.value(QStringLiteral("minimumTap")).toInt();
const int maximumTap = arguments.value(QStringLiteral("maximumTap")).toInt();
bool ageValid, pauseValid, tapValid, maximumValid;
const int minimumAnchorAge = arguments.value(QStringLiteral("minimumAnchorAge")).toInt(&ageValid);
const int minimumPause = arguments.value(QStringLiteral("minimumPause")).toInt(&pauseValid);
const int minimumTap = arguments.value(QStringLiteral("minimumTap")).toInt(&tapValid);
const int maximumTap = arguments.value(QStringLiteral("maximumTap")).toInt(&maximumValid);
const QString outputEvent = arguments.value(QStringLiteral("outputEvent")).toString();
static const QRegularExpression eventName(QStringLiteral("^(BTN|KEY)_[A-Z0-9_]+$"));
if (minimumAnchorAge < 0 || minimumAnchorAge > 2000
|| minimumPause < 0 || minimumPause > 2000
|| minimumTap < 0 || minimumTap > maximumTap
|| maximumTap < 1 || maximumTap > 2000
|| !eventName.match(outputEvent).hasMatch()) {
if (!ageValid || !pauseValid || !tapValid || !maximumValid
|| !validTouchpadSettings(minimumAnchorAge, minimumPause, minimumTap, maximumTap, outputEvent)) {
KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply();
reply.addData(QStringLiteral("message"), QStringLiteral("The requested touchpad settings are invalid."));
return reply;
+12 -1
View File
@@ -36,7 +36,7 @@ QList<PackageRecord> parsePackageRecords(const QByteArray &output)
bool isValidPackageName(const QString &name)
{
static const QRegularExpression expression(QStringLiteral("^[a-z0-9][a-z0-9+._-]*$"));
static const QRegularExpression expression(QStringLiteral("\\A[a-z0-9][a-z0-9+._-]*\\z"));
return expression.match(name).hasMatch();
}
@@ -51,3 +51,14 @@ QString packageCompatibilityError(const QString &name, const QString &sys)
&& read(sys + QStringLiteral("/class/dmi/id/product_name")) == QLatin1String("Laptop 13 Pro (Intel Core Ultra Series 3)")) return {};
return QStringLiteral("Incompatible: requires Framework Laptop 13 Pro (Intel Core Ultra Series 3).");
}
bool validTouchpadSettings(int minimumAnchorAge, int minimumPause, int minimumTap,
int maximumTap, const QString &outputEvent)
{
static const QRegularExpression eventName(QStringLiteral("\\A(BTN|KEY)_[A-Z0-9_]+\\z"));
return minimumAnchorAge >= 0 && minimumAnchorAge <= 2000
&& minimumPause >= 0 && minimumPause <= 2000
&& minimumTap >= 0 && minimumTap <= maximumTap
&& maximumTap >= 1 && maximumTap <= 2000
&& eventName.match(outputEvent).hasMatch();
}
+2
View File
@@ -16,4 +16,6 @@ struct PackageRecord
QList<PackageRecord> parsePackageRecords(const QByteArray &output);
bool isValidPackageName(const QString &name);
bool validTouchpadSettings(int minimumAnchorAge, int minimumPause, int minimumTap,
int maximumTap, const QString &outputEvent);
QString packageCompatibilityError(const QString &name, const QString &sys = QStringLiteral("/sys"));
+1 -6
View File
@@ -150,13 +150,8 @@ void ToolModel::setPackages(const QList<PackageRecord> &installed,
tool.updateAvailable = tool.available && tool.installed && updates.contains(package.name);
}
QList<Tool> merged = tools.values();
std::ranges::sort(merged, [](const Tool &left, const Tool &right) {
return left.packageName < right.packageName;
});
beginResetModel();
m_tools = std::move(merged);
m_tools = tools.values(); // QMap already orders entries by package name.
endResetModel();
}
@@ -12,6 +12,17 @@ class PackageUtilsTest : public QObject
Q_OBJECT
private Q_SLOTS:
void validatesTouchpadSettings()
{
QVERIFY(validTouchpadSettings(100, 100, 10, 150, QStringLiteral("BTN_MIDDLE")));
QVERIFY(validTouchpadSettings(0, 0, 0, 2000, QStringLiteral("KEY_F13")));
QVERIFY(!validTouchpadSettings(-1, 100, 10, 150, QStringLiteral("BTN_MIDDLE")));
QVERIFY(!validTouchpadSettings(100, 2001, 10, 150, QStringLiteral("BTN_MIDDLE")));
QVERIFY(!validTouchpadSettings(100, 100, 151, 150, QStringLiteral("BTN_MIDDLE")));
QVERIFY(!validTouchpadSettings(100, 100, 0, 0, QStringLiteral("BTN_MIDDLE")));
QVERIFY(!validTouchpadSettings(100, 100, 10, 150, QStringLiteral("BTN_MIDDLE\n")));
QVERIFY(!validTouchpadSettings(100, 100, 10, 150, QStringLiteral("BTN_MIDDLE; reboot")));
}
void frameworkCompatibility() {
QTemporaryDir root;
QVERIFY(packageCompatibilityError(QStringLiteral("touchpad-hold-tap"), root.path()).isEmpty());
@@ -43,6 +54,7 @@ private Q_SLOTS:
QVERIFY(!isValidPackageName(QStringLiteral("--installroot=/tmp")));
QVERIFY(!isValidPackageName(QStringLiteral("tool; reboot")));
QVERIFY(!isValidPackageName(QStringLiteral("Tool")));
QVERIFY(!isValidPackageName(QStringLiteral("touchpad-hold-tap\n")));
}
};
+3 -2
View File
@@ -8,12 +8,12 @@ set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
include(KDEInstallDirs)
include(KDECMakeSettings)
include(CTest)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Widgets DBus Test)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Widgets DBus)
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)
add_library(framework-hardware STATIC src/hardware.cpp src/fan.cpp src/cpu.cpp src/power.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 src/tooltip.cpp src/processusage.cpp)
@@ -36,6 +36,7 @@ install(FILES data/framework-laptop-tools.json DESTINATION ${KDE_INSTALL_DATADIR
install(FILES data/framework-laptop-tools-fan.service data/framework-laptop-tools-fan-resume.service DESTINATION /usr/lib/systemd/system)
install(FILES data/framework-laptop-tools-cpu.service DESTINATION /usr/lib/systemd/system)
if(BUILD_TESTING)
find_package(Qt6 6.8 REQUIRED COMPONENTS Test)
add_executable(test-hardware tests/test-hardware.cpp)
target_link_libraries(test-hardware PRIVATE framework-hardware Qt6::Test)
add_test(NAME hardware COMMAND test-hardware)
+36 -9
View File
@@ -36,7 +36,8 @@ its colour; existing enabled lines keep theirs. Disabled colours are released.
Axes use round ticks and relative ages, with units below the vertical axes.
Hover draws a guide at the same timestamp on every graph, even when their
time spans differ. The tooltip still describes only the graph under the pointer.
time spans differ. The popup describes only the graph under the pointer, stays
visible while hovering and follows new samples without needing mouse movement.
Hover for timestamps and the nearest
available readings; no readings are invented across gaps or sleep. Battery charge
uses the left percentage axis and rate the right watts axis (positive charging,
@@ -49,21 +50,45 @@ 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.
Changing the sampling interval preserves older lines and hover readings at their
original cadence; missing readings and sleep remain gaps.
Monitor and tray graphs fill each pixel column from its time-weighted average
to its maximum when readings are compressed. With sparse readings, columns
follow straight lines between samples. Bands have a minimum vertical height of
two logical pixels, padded equally above and below; they never widen sideways.
Monitor columns align to physical pixels and the vertical minimum follows display
scaling. Plasma scales the tray's 64-pixel icon image to its chosen display size.
Missing data and sleep remain gaps. Hover readings report the original samples.
Sampling choices are 0.5/1/2/4 seconds for frequencies and temperatures, and
Power usage shows the detected RAPL domains (CPU package, cores, uncore and memory)
and hwmon power readings. Unvalidated platform power and ACPI fan power-table
entries are excluded; fan speed and duty cycle remain available separately.
These domains overlap and must not be added together. Uncore coverage depends
on the hardware; it is not labelled GPU power.
RAPL watts are energy-counter differences divided by elapsed monotonic time.
Wraparound is handled; missing reads, long gaps, detected resets and sleep restart
the baseline instead of producing a spike. MSR/MMIO duplicates are not plotted
twice. The counters must be readable; this app does not change their permissions.
Power readings are also available as tray metrics, using the same history.
Sampling choices are 0.5/1/2/4 seconds for frequencies, power, fan and temperatures, and
15/30/60/120 seconds for the battery graph. Tray autostart is optional.
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
The Tray icon tab supports one to ten independently configured icons, each showing
an application icon, a history graph, or a number. Settings cards wrap to fit the
window. New icons default to the application icon; your existing configuration
becomes Icon 1. Reducing the count keeps hidden drafts until Save and Apply;
saving retains only visible icons. The Move icon arrows swap all settings with
the adjacent icon while keeping the position numbers fixed. Undo restores the
saved set. All icons share
sensor sampling and history, and each has its own appearance and hover choices.
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 24 hours. Switching
readings or resuming from sleep does not clear history. Frequency numbers use
GHz. The tray has optional
borders, a transparent or coloured background, line and fill colours, and optional
borders, a background colour with adjustable opacity, line and fill colours, and optional
area fill (including adjustable opacity). Frequency ceilings and temperature
ranges are saved per sensor; battery rate also has adjustable bounds (initially
7575 W), while percentages use 0100.
@@ -74,6 +99,8 @@ 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.
Background opacity can be zero for full transparency; the former Transparent
background checkbox migrates to zero opacity without changing the 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
@@ -82,7 +109,7 @@ 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
order. Process sampling stops when no icon requests it; 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
@@ -1,6 +1,6 @@
Name: framework-laptop-tools
Version: 0.1.0
Release: 15%{?dist}
Release: 25%{?dist}
Summary: Hardware controls and monitoring for Framework Laptop 13 Pro
License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools
@@ -56,6 +56,12 @@ install -Dpm 0644 %{SOURCE1} %{buildroot}%{_licensedir}/%{name}/LICENSE
%{_unitdir}/framework-laptop-tools-fan-resume.service
%{_unitdir}/framework-laptop-tools-cpu.service
%changelog
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-25
- Add multiple tray icons, opacity controls and component power monitoring
- Refine graph columns, live hover readings and sampling-interval changes
- Exclude unvalidated platform and fan power readings
- Cache graph rendering and simplify telemetry and settings persistence
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-15
- Expand tray hover details and allow zero to three top CPU applications
+125 -43
View File
@@ -2,9 +2,10 @@
#include "chart.h"
#include "graphdata.h"
#include <QPainter>
#include <QPainterPath>
#include <QMouseEvent>
#include <QToolTip>
#include <QGuiApplication>
#include <QScreen>
#include <QVector3D>
#include <algorithm>
#include <cmath>
@@ -56,17 +57,15 @@ AxisTicks AxisTicks::covering(double minimum, double maximum, int intervals)
}
double timeTickStep(double spanMs, int intervals)
{
const double target = spanMs / 1000 / std::max(1, intervals);
for (double seconds : {1., 2., 5., 10., 15., 30., 60., 120., 300., 600., 900., 1800., 3600., 7200., 10800., 21600., 43200., 86400.})
if (seconds >= target) return seconds * 1000;
return niceStep(target / 86400) * 86400000;
const double unit = spanMs >= 3600000 ? 3600000 : spanMs >= 60000 ? 60000 : 1000;
return niceStep(spanMs / unit / std::max(1, intervals)) * unit;
}
QString ageLabel(double milliseconds)
QString ageLabel(double milliseconds, double spanMs)
{
const qint64 seconds = qRound64(milliseconds / 1000);
const double seconds = milliseconds / 1000;
if (!seconds) return "Now";
if (seconds % 3600 == 0) return QString::number(seconds / 3600) + "h";
if (seconds % 60 == 0) return QString::number(seconds / 60) + "min";
if (spanMs >= 3600000) return QString::number(seconds / 3600, 'g', 3) + "h";
if (spanMs >= 60000) return QString::number(seconds / 60, 'g', 3) + "min";
return QString::number(seconds) + "s";
}
Chart::Chart(const QString &unit, QWidget *parent) : QWidget(parent), m_unit(unit)
@@ -74,11 +73,21 @@ Chart::Chart(const QString &unit, QWidget *parent) : QWidget(parent), m_unit(uni
setMinimumHeight(175);
setMouseTracking(true);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
m_popup = new QLabel(this, Qt::ToolTip | Qt::WindowTransparentForInput | Qt::WindowDoesNotAcceptFocus);
m_popup->setObjectName("chartReadingPopup");
m_popup->setTextFormat(Qt::PlainText);
m_popup->setPalette(QToolTip::palette());
m_popup->setBackgroundRole(QPalette::ToolTipBase);
m_popup->setForegroundRole(QPalette::ToolTipText);
m_popup->setAutoFillBackground(true);
m_popup->setMargin(6);
m_popup->setFrameStyle(QFrame::Box | QFrame::Plain);
}
void Chart::addSeries(const QString &id, const QString &name, const QString &unit)
{
m_series.insert(id, {QColor(),
name.isEmpty() ? id : name, unit.isEmpty() ? m_unit : unit, {}});
invalidatePlot();
}
void Chart::setSelected(const QString &id, bool selected)
{
@@ -89,7 +98,7 @@ void Chart::setSelected(const QString &id, bool selected)
m_series[id].color = nextColour(used);
m_selected.insert(id);
} else m_selected.remove(id);
update();
invalidatePlot();
}
QColor Chart::color(const QString &id) const
{
@@ -103,7 +112,7 @@ QVector<QPointF> Chart::history(const QString &id, double since) const
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())))
if (!points.isEmpty() && !connectedSamples(points.last().x(), point.x()))
points.append({point.x(), NAN});
points.append(point);
}
@@ -111,7 +120,8 @@ QVector<QPointF> Chart::history(const QString &id, double since) const
}
void Chart::sample(const QMap<QString, double> &values, int intervalMs, qint64 now)
{
m_interval = intervalMs;
// Keep cadence changes with the history, not one current interval for all samples.
if (m_intervals.isEmpty() || m_intervals.last() != intervalMs) m_intervals.insert(now, intervalMs);
m_sampleTime = now;
double oldest = now;
for (auto it = m_series.begin(); it != m_series.end(); ++it) {
@@ -119,19 +129,22 @@ void Chart::sample(const QMap<QString, double> &values, int intervalMs, qint64 n
oldest = std::min(oldest, it->points.front().x());
}
m_bands.removeIf([oldest](const Band &band) { return band.end && band.end < oldest; });
update();
while (m_intervals.size() > 1 && std::next(m_intervals.cbegin()).key() <= oldest)
m_intervals.erase(m_intervals.begin());
invalidatePlot();
}
void Chart::setPowerState(bool sleeping, std::optional<bool> plugged, qint64 now)
{
// Charger state cannot be observed during suspend; don't extend AC bands through it.
const std::optional<BandType> next = sleeping ? std::optional(BandType::Sleep)
: plugged.value_or(false) ? std::optional(BandType::Plugged) : std::nullopt;
if (!next && (m_bands.isEmpty() || m_bands.last().end)) return;
if (!m_bands.isEmpty() && !m_bands.last().end) {
if (next == m_bands.last().type) return;
m_bands.last().end = now;
}
if (next) m_bands.append({now, 0, *next});
update();
invalidatePlot();
}
bool Chart::crossesSleep(double from, double to) const
{
@@ -139,11 +152,22 @@ 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;
}
int Chart::intervalAt(double time) const
{
const auto next = m_intervals.upperBound(qint64(time));
Q_ASSERT(next != m_intervals.cbegin()); // Every retained sample has a cadence entry.
return std::prev(next).value();
}
bool Chart::connectedSamples(double from, double to) const
{
const int interval = std::max(intervalAt(from), intervalAt(to));
return to - from <= interval * 3 && !crossesSleep(from, to);
}
void Chart::setHistoryWindow(qint64 spanMs, bool stretch)
{
m_historyMs = std::clamp(spanMs, qint64(300000), historyRetentionMs);
m_stretch = stretch;
update();
invalidatePlot();
}
QPair<double, double> Chart::timeRange() const
{
@@ -179,7 +203,10 @@ Chart::Frame Chart::frame() const
}
const int timeTextWidth = fm.horizontalAdvance("100min");
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);
QRectF area(margin, fm.height() / 2 + 4, width() - margin - (secondary ? margin : 25), height() - 2 * fm.height() - 20);
const double ratio = devicePixelRatioF();
area.setLeft(std::ceil(area.left() * ratio) / ratio);
area.setRight(std::floor(area.right() * ratio) / ratio);
const int intervals = std::max(2, int(area.height()) / (fm.height() * 2));
return {area, first, now, AxisTicks::covering(low, high, intervals),
AxisTicks::covering(low2, high2, intervals), secondary};
@@ -198,7 +225,10 @@ QString Chart::readingAt(qint64 time) const
auto next = std::lower_bound(points.cbegin(), points.cend(), double(time), [](const QPointF &p, double t) { return p.x() < t; });
const QPointF *closest = next != points.cend() ? &*next : nullptr;
if (next != points.cbegin() && (!closest || time - (next - 1)->x() < closest->x() - time)) closest = &*(next - 1);
const bool valid = !sleeping && closest && std::abs(closest->x() - time) <= m_interval * 1.5
int interval = closest ? intervalAt(closest->x()) : 0;
if (next != points.cend()) interval = std::max(interval, intervalAt(next->x()));
if (next != points.cbegin()) interval = std::max(interval, intervalAt((next - 1)->x()));
const bool valid = !sleeping && closest && std::abs(closest->x() - time) <= interval * 1.5
&& std::isfinite(closest->y()) && !crossesSleep(std::min(closest->x(), double(time)), std::max(closest->x(), double(time)));
lines.append(it->name + ": " + (valid ? number(closest->y()) + " " + it->unit : ""));
}
@@ -206,17 +236,78 @@ QString Chart::readingAt(qint64 time) const
}
void Chart::mouseMoveEvent(QMouseEvent *event)
{
const auto f = frame();
if (!f.area.contains(event->position())) { setHoverTime(-1); Q_EMIT hovered(-1); QToolTip::hideText(); return; }
const qint64 time = qRound64(f.first + (event->position().x() - f.area.left()) / f.area.width() * (f.last - f.first));
m_hoverPosition = event->position();
refreshHover();
}
void Chart::refreshHover()
{
if (!m_hoverPosition || !isVisible()) return;
ensurePlot();
const auto &f = m_frame;
if (!f.area.contains(*m_hoverPosition)) { clearHover(); return; }
const qint64 time = qRound64(f.first + (m_hoverPosition->x() - f.area.left()) / f.area.width() * (f.last - f.first));
setHoverTime(time); Q_EMIT hovered(time);
QToolTip::showText(event->globalPosition().toPoint() + QPoint(12, 16), readingAt(time), this);
m_popup->setText(readingAt(time));
m_popup->adjustSize();
const auto cursor = mapToGlobal(m_hoverPosition->toPoint());
auto position = cursor + QPoint(12, 16);
const auto *screen = QGuiApplication::screenAt(cursor);
if (screen) {
const auto bounds = screen->availableGeometry();
position.setX(std::max(bounds.left(), std::min(position.x(), bounds.right() - m_popup->width() + 1)));
if (position.y() + m_popup->height() > bounds.bottom()) position.setY(cursor.y() - m_popup->height() - 8);
position.setY(std::max(bounds.top(), position.y()));
}
m_popup->move(position);
m_popup->show();
}
void Chart::clearHover()
{
m_hoverPosition.reset(); setHoverTime(-1); Q_EMIT hovered(-1); m_popup->hide();
}
void Chart::leaveEvent(QEvent *event) { clearHover(); QWidget::leaveEvent(event); }
void Chart::hideEvent(QHideEvent *event) { clearHover(); QWidget::hideEvent(event); }
void Chart::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); invalidatePlot(); }
void Chart::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (event->type() == QEvent::FontChange || event->type() == QEvent::PaletteChange
|| event->type() == QEvent::StyleChange) invalidatePlot();
}
void Chart::invalidatePlot()
{
m_plot = QPixmap();
refreshHover();
update();
}
void Chart::ensurePlot()
{
const auto pixels = size() * devicePixelRatioF();
if (!m_plot.isNull() && m_plot.size() == pixels && m_plot.devicePixelRatio() == devicePixelRatioF()) return;
m_frame = frame();
m_plot = QPixmap(pixels);
m_plot.setDevicePixelRatio(devicePixelRatioF());
m_plot.fill(Qt::transparent);
QPainter painter(&m_plot);
drawPlot(painter, m_frame);
}
void Chart::leaveEvent(QEvent *event) { setHoverTime(-1); Q_EMIT hovered(-1); QToolTip::hideText(); QWidget::leaveEvent(event); }
void Chart::paintEvent(QPaintEvent *)
{
QPainter p(this); p.setRenderHint(QPainter::Antialiasing);
const auto f = frame(); const auto area = f.area;
// Hover moves only the overlay, not the potentially 24-hour history underneath.
ensurePlot();
QPainter p(this);
p.drawPixmap(0, 0, m_plot);
const auto &f = m_frame;
if (m_hoverTime && *m_hoverTime >= f.first && *m_hoverTime <= f.last && f.area.isValid()) {
const double x = f.area.left() + (*m_hoverTime - f.first) / (f.last - f.first) * f.area.width();
p.setPen(QPen(palette().color(QPalette::Text), 1, Qt::DashLine));
p.drawLine(QPointF(x, f.area.top()), QPointF(x, f.area.bottom()));
}
}
void Chart::drawPlot(QPainter &p, const Frame &f)
{
p.setRenderHint(QPainter::Antialiasing);
const auto area = f.area;
if (area.width() <= 0 || area.height() <= 0) return;
const auto x = [&](double time) { return area.left() + (time - f.first) / (f.last - f.first) * area.width(); };
const auto y = [&](double value, const AxisTicks &axis) { return area.bottom() - (value - axis.minimum) / (axis.maximum - axis.minimum) * area.height(); };
@@ -261,34 +352,25 @@ void Chart::paintEvent(QPaintEvent *)
const double pos = x(f.last - age);
p.setPen(grid); p.drawLine(QPointF(pos, area.top()), QPointF(pos, area.bottom()));
p.setPen(text); p.drawLine(QPointF(pos, area.bottom()), QPointF(pos, area.bottom() + 6));
p.setPen(text); p.drawText(QRectF(pos - labelWidth / 2., area.bottom() + 7, labelWidth, h), Qt::AlignHCenter, ageLabel(age));
p.setPen(text); p.drawText(QRectF(pos - labelWidth / 2., area.bottom() + 7, labelWidth, h), Qt::AlignHCenter, ageLabel(age, f.last - f.first));
}
p.save(); p.setClipRect(area.adjusted(-1, -1, 1, 1));
p.save(); p.setClipRect(area.adjusted(0, -1, 0, 1));
p.setRenderHint(QPainter::Antialiasing, false);
bool hasValues = false;
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
if (!selected(it.key())) continue;
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());
f.first, f.last, qRound(area.width() * devicePixelRatioF()), [this](const QPointF &a, const QPointF &b) {
return connectedSamples(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; }
const QPointF position(x(point.x()), y(point.y(), scale));
if (connected) line.lineTo(position); else line.moveTo(position);
connected = true; hasValues = true;
for (const auto &column : columns) {
const double left = area.left() + column.index / devicePixelRatioF();
p.fillRect(columnBand(left, left + 1 / devicePixelRatioF(), y(column.maximum, scale),
y(column.lower(), scale), 2, devicePixelRatioF()), it->color);
}
p.setPen(QPen(it->color, 2)); p.drawPath(line);
hasValues |= !columns.isEmpty();
}
p.restore();
if (m_hoverTime && *m_hoverTime >= f.first && *m_hoverTime <= f.last) {
p.setPen(QPen(text, 1, Qt::DashLine));
p.drawLine(QPointF(x(*m_hoverTime), area.top()), QPointF(x(*m_hoverTime), area.bottom()));
}
if (!hasValues) { p.setPen(text); p.drawText(area, Qt::AlignCenter, "No selected readings available"); }
}
+18 -2
View File
@@ -5,6 +5,8 @@
#include <QMap>
#include <QSet>
#include <optional>
#include <QLabel>
#include <QPixmap>
#include "history.h"
struct AxisTicks {
@@ -12,7 +14,7 @@ struct AxisTicks {
static AxisTicks covering(double minimum, double maximum, int intervals);
};
double timeTickStep(double spanMs, int intervals);
QString ageLabel(double milliseconds);
QString ageLabel(double milliseconds, double spanMs);
class Chart : public QWidget {
Q_OBJECT
@@ -38,20 +40,34 @@ protected:
void paintEvent(QPaintEvent *) override;
void mouseMoveEvent(QMouseEvent *event) override;
void leaveEvent(QEvent *event) override;
void hideEvent(QHideEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void changeEvent(QEvent *event) override;
private:
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; };
Frame frame() const;
void invalidatePlot();
void ensurePlot();
void drawPlot(QPainter &painter, const Frame &frame);
void refreshHover();
void clearHover();
bool crossesSleep(double from, double to) const;
int intervalAt(double time) const;
bool connectedSamples(double from, double to) const;
QMap<QString, Series> m_series;
QSet<QString> m_selected;
QVector<Band> m_bands;
QString m_unit;
int m_interval = 1000;
QMap<qint64, int> m_intervals;
qint64 m_historyMs = historyRetentionMs;
bool m_stretch = true;
qint64 m_sampleTime = QDateTime::currentMSecsSinceEpoch();
std::optional<qint64> m_hoverTime;
std::optional<QPointF> m_hoverPosition;
QLabel *m_popup;
QPixmap m_plot;
Frame m_frame;
};
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: MIT
#include "cpupage.h"
#include "widgets.h"
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
@@ -33,7 +34,7 @@ void CpuPage::Editor::setValue(const QVariantMap &value)
}
CpuPage::Editor CpuPage::makeEditor(const QString &name)
{
Editor editor{new QGroupBox(name), new QCheckBox("Override frequency bounds"),
Editor editor{sectionGroup(name), new QCheckBox("Override frequency bounds"),
new ValueControl, new ValueControl, new QComboBox, new QComboBox, new QLabel};
editor.frequency->setObjectName("overrideFrequency");
editor.governor->setObjectName("cpuGovernor"); editor.preference->setObjectName("cpuPreference");
+19 -17
View File
@@ -1,13 +1,16 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QPointF>
#include <QRectF>
#include <QVector>
#include <algorithm>
#include <cmath>
struct AverageColumn {
double begin, end, mean, maximum;
bool startsRun;
double begin, end, mean, maximum, minimum;
bool compressed;
int index;
double lower() const { return compressed ? mean : minimum; }
};
// Integrate the piecewise-linear signal over each pixel's time interval.
@@ -35,31 +38,30 @@ QVector<AverageColumn> timeAverages(Iterator begin, Iterator end, double first,
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);
const double mean = (low + high) / 2, maximum = std::max(low, high), minimum = std::min(low, high);
// Choose locally so histories with changing sampling intervals also work.
const bool compressed = b.x() - a.x() < step;
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.minimum = std::min(bucket.minimum, minimum);
bucket.compressed |= compressed;
bucket.end = stop;
} else result.append({left, stop, mean, maximum, startsRun});
} else result.append({left, stop, mean, maximum, minimum, compressed, column});
startsRun = false; previousColumn = column; left = stop;
}
}
return result;
}
inline QVector<QPointF> averageLine(const QVector<AverageColumn> &columns)
// Fill the covered vertical pixels, padding short bands equally above and below.
// X boundaries are untouched: neither slope nor thickness widens a column.
inline QRectF columnBand(double left, double right, double top, double bottom,
double minimumHeight = 2, double pixelRatio = 1)
{
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;
const double padding = std::max(0., minimumHeight - (bottom - top)) / 2;
top = std::floor((top - padding) * pixelRatio) / pixelRatio;
bottom = std::ceil((bottom + padding) * pixelRatio) / pixelRatio;
return QRectF(QPointF(left, top), QPointF(right, bottom));
}
+22 -19
View File
@@ -10,6 +10,19 @@
#include <QProcess>
#include <QSaveFile>
namespace {
QString saveConfig(const QString &path, const QVariantMap &config)
{
if (!QDir().mkpath(QFileInfo(path).absolutePath())) return "Cannot create settings directory.";
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly)
|| !file.setPermissions(QFile::ReadOwner | QFile::WriteOwner)) return file.errorString();
const auto data = QJsonDocument::fromVariant(config).toJson(QJsonDocument::Compact);
if (file.write(data) != data.size() || !file.commit()) return file.errorString();
return {};
}
}
class Helper : public QObject {
Q_OBJECT
KAuth::ActionReply reply(const QString &error, const QVariantMap &data = {}) {
@@ -20,7 +33,9 @@ class Helper : public QObject {
QProcess process;
process.start("/usr/bin/systemctl", {verb, unit});
if (!process.waitForFinished(25000)) { process.kill(); process.waitForFinished(); return "Service did not respond."; }
return process.exitCode() == 0 ? QString() : QString::fromUtf8(process.readAllStandardError());
if (process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0) return {};
const auto error = QString::fromUtf8(process.readAllStandardError()).trimmed();
return error.isEmpty() ? "Service operation failed: " + verb + " " + unit : error;
}
public Q_SLOTS:
KAuth::ActionReply inspect(const QVariantMap &) {
@@ -50,7 +65,6 @@ public Q_SLOTS:
}
}
}
data["fanPersistent"] = QFile::exists(savedFanConfigPath);
return reply({}, data);
}
KAuth::ActionReply configure(const QVariantMap &args) {
@@ -80,12 +94,8 @@ public Q_SLOTS:
const auto config = normalizeCpuConfig(args.value("config").toMap());
error = validateCpuConfig(config);
if (!error.isEmpty()) return reply(error);
if (!QDir().mkpath("/etc/framework-laptop-tools")) return reply("Cannot create CPU configuration directory.");
QSaveFile file(cpuConfigPath);
if (!file.open(QIODevice::WriteOnly)) return reply(file.errorString());
file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
const auto data = QJsonDocument::fromVariant(config).toJson();
if (file.write(data) != data.size() || !file.commit()) return reply(file.errorString());
error = saveConfig(cpuConfigPath, config);
if (!error.isEmpty()) return reply(error);
const QString unit = "framework-laptop-tools-cpu.service";
if (hasCpuOverrides(config)) {
error = service("enable", unit);
@@ -101,23 +111,16 @@ public Q_SLOTS:
// Restore the old worker before replacing its configuration.
error = service("stop");
if (!error.isEmpty()) return reply(error);
if (QFile::exists(savedFanConfigPath) && !QFile::remove(savedFanConfigPath)) return reply("Cannot remove saved fan settings.");
const bool persistent = args.value("mode") != "auto";
if (!persistent && QFile::exists(savedFanConfigPath) && !QFile::remove(savedFanConfigPath))
return reply("Cannot remove saved fan settings.");
error = service(persistent ? "enable" : "disable");
if (error.isEmpty()) error = service(persistent ? "enable" : "disable", "framework-laptop-tools-fan-resume.service");
if (!error.isEmpty()) return reply(error);
if (args.value("mode") == "auto") error = restoreFan();
else {
const QString path = persistent ? savedFanConfigPath : fanConfigPath;
if (!QDir().mkpath(QFileInfo(path).absolutePath())) error = "Cannot create fan settings directory.";
if (error.isEmpty()) {
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly)) return reply(file.errorString());
file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
const auto data = QJsonDocument::fromVariant(args).toJson(QJsonDocument::Compact);
if (file.write(data) != data.size() || !file.commit()) return reply(file.errorString());
error = service("start");
}
error = saveConfig(savedFanConfigPath, args);
if (error.isEmpty()) error = service("start");
}
} else if (operation == "powerAuto") error = setFirmwareValue(operation, 0);
else if (value >= 0) error = setFirmwareValue(operation, value);
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MIT
#include "power.h"
#include <QDir>
#include <QFileInfo>
#include <QSet>
std::optional<double> EnergyCounter::sample(quint64 energy, quint64 range, qint64 timeMs, int maxGapMs)
{
const auto previous = m_previous;
m_previous = Reading{energy, range, timeMs};
if (!range || energy > range) { m_previous.reset(); return {}; }
if (!previous || previous->range != range) return {};
const qint64 elapsed = timeMs - previous->time;
if (elapsed <= 0 || elapsed > maxGapMs) return {};
quint64 delta;
if (energy >= previous->energy) delta = energy - previous->energy;
else {
// A small backwards jump is a reset, not a nearly full counter's worth of energy.
if (previous->energy - energy < range / 2) return {};
delta = range - previous->energy + energy;
}
return double(delta) / (1000. * elapsed); // microjoules / milliseconds -> watts
}
PowerMonitor::PowerMonitor(const QString &sys)
{
m_clock.start();
const QDir powercap(sys + "/class/powercap");
auto zones = powercap.entryList({"intel-rapl:*"}, QDir::Dirs | QDir::NoDotAndDotDot);
QString prefix = "intel-rapl:";
// MSR and MMIO interfaces can expose the same domains. Use one, not both.
if (zones.isEmpty()) {
prefix = "intel-rapl-mmio:";
zones = powercap.entryList({prefix + "*"}, QDir::Dirs | QDir::NoDotAndDotDot);
}
const QMap<QString, QString> names{{"core", "CPU cores"}, {"uncore", "Uncore"},
{"dram", "Memory (RAPL)"}};
for (const auto &zone : zones) {
const QString name = readText(powercap.filePath(zone + "/name"));
// PSYS watts contradicted battery draw on the tested hardware; do not expose them.
if (name == "psys" || name.startsWith("psys-")) continue;
const QString path = powercap.filePath(zone + "/energy_uj");
if (name.isEmpty() || !QFileInfo::exists(path)) continue;
const QString label = name == "package-0" ? "CPU package" : names.value(name, name);
m_sensors.append({"power/rapl/" + zone.mid(prefix.size()), label, path, "W"});
}
const QDir hwmon(sys + "/class/hwmon");
for (const auto &entry : hwmon.entryList({"hwmon*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
const QDir dir(hwmon.filePath(entry));
const QString chip = readText(dir.filePath("name"));
// ACPI fan power is a firmware table entry, often absent, not live telemetry.
if (chip == "acpi_fan") continue;
QSet<QString> added;
for (const auto &file : dir.entryList({"power*_input", "power*_average"}, QDir::Files)) {
const QString stem = file.section('_', 0, 0);
if (added.contains(stem)) continue;
added.insert(stem);
const QString input = dir.filePath(stem + "_input");
const QString path = QFileInfo::exists(input) ? input : dir.filePath(file);
const QString raw = readText(dir.filePath(stem + "_label"));
const QString label = chip + "" + (raw.isEmpty() ? stem : raw);
m_sensors.append({"power/" + chip + "/" + stem, label, path, "W", 1e-6});
}
}
}
QMap<QString, double> PowerMonitor::sample(int intervalMs, qint64 timeMs)
{
if (timeMs < 0) timeMs = m_clock.elapsed();
QMap<QString, double> values;
for (const auto &sensor : m_sensors) {
std::optional<double> watts;
if (sensor.path.endsWith("/energy_uj")) {
bool energyOk, rangeOk;
const quint64 energy = readText(sensor.path).toULongLong(&energyOk);
const quint64 range = readText(QFileInfo(sensor.path).dir().filePath("max_energy_range_uj")).toULongLong(&rangeOk);
if (energyOk && rangeOk) watts = m_counters[sensor.id].sample(energy, range, timeMs, 3 * intervalMs);
else m_counters.remove(sensor.id); // Don't bridge a failed read with the next sample.
} else watts = sensorValue(sensor);
if (watts && *watts >= 0) values[sensor.id] = *watts;
}
return values;
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "hardware.h"
#include <QElapsedTimer>
class EnergyCounter {
public:
std::optional<double> sample(quint64 energy, quint64 range, qint64 timeMs, int maxGapMs);
private:
struct Reading { quint64 energy, range; qint64 time; };
std::optional<Reading> m_previous;
};
class PowerMonitor {
public:
explicit PowerMonitor(const QString &sys = QStringLiteral("/sys"));
const QVector<Sensor> &sensors() const { return m_sensors; }
QMap<QString, double> sample(int intervalMs, qint64 timeMs = -1);
void reset() { m_counters.clear(); }
private:
QVector<Sensor> m_sensors;
QMap<QString, EnergyCounter> m_counters;
QElapsedTimer m_clock;
};
+3 -2
View File
@@ -76,9 +76,10 @@ QVector<BusyApp> ProcessUsage::sample(std::optional<quint64> totalDelta, const Q
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) {
const auto count = std::min(qsizetype(3), apps.size());
std::partial_sort(apps.begin(), apps.begin() + count, 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);
apps.resize(count);
return apps;
}
+19 -37
View File
@@ -2,7 +2,6 @@
#include "tray.h"
#include "graphdata.h"
#include <QPainter>
#include <QPainterPath>
#include <cmath>
#include <algorithm>
@@ -28,12 +27,12 @@ std::optional<double> CpuUsage::sample(const QString &procStat)
}
QRectF trayPlotRect(bool border)
{
// Two-pixel border; leave room for the graph's two-pixel stroke inside it.
// Two-pixel border; reserve space for the graph's vertical padding 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, qint64 now)
{
QPixmap pixmap(64, 64); pixmap.fill(style.transparent ? Qt::transparent : style.backgroundColor);
QPixmap pixmap(64, 64); pixmap.fill(style.backgroundColor);
QPainter p(&pixmap); p.setRenderHint(QPainter::Antialiasing);
if (style.border) { p.setPen(QPen(style.borderColor, 2)); p.drawRect(QRectF(1, 1, 62, 62)); }
const auto area = trayPlotRect(style.border);
@@ -42,47 +41,30 @@ QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &u
if (graph && !values.isEmpty() && style.maximum > style.minimum) {
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());
p.save(); p.setClipRect(area.adjusted(0, -1, 0, 1));
p.setRenderHint(QPainter::Antialiasing, false);
const auto y = [&](double value) {
const double fraction = std::clamp((value - style.minimum) / (style.maximum - style.minimum), 0., 1.);
return 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; };
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}) {
const double t = (boundary - a.y()) / (b.y() - a.y());
if (t > 0 && t < 1) cuts.append(t);
}
std::sort(cuts.begin(), cuts.end());
for (int j = 1; j < cuts.size(); ++j) {
const bool out = outside(a.y() + (b.y() - a.y()) * (cuts[j - 1] + cuts[j]) / 2);
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) {
fill.moveTo(left); fill.lineTo(right);
fill.lineTo(right.x(), area.bottom()); fill.lineTo(left.x(), area.bottom()); fill.closeSubpath();
}
auto &path = out ? overflow : line;
path.moveTo(left); path.lineTo(right);
const double left = area.left() + column.index, right = left + 1;
const double top = y(column.maximum), bottom = y(column.lower());
const bool visible = column.maximum >= style.minimum && column.lower() <= style.maximum;
if (style.fill && (visible || style.clamp))
p.fillRect(QRectF(left, bottom, 1, area.bottom() - bottom), style.fillColor);
if (visible)
p.fillRect(columnBand(left, right, top, bottom), style.lineColor);
if (style.clamp) {
if (column.maximum > style.maximum)
p.fillRect(columnBand(left, right, area.top(), area.top()), color(true));
if (column.lower() < style.minimum)
p.fillRect(columnBand(left, right, area.bottom(), area.bottom()), color(true));
}
}
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"
+1 -1
View File
@@ -17,7 +17,7 @@ private:
std::optional<quint64> m_delta;
};
struct TrayStyle {
bool border = true, transparent = false, fill = true, clamp = true, overflowColor = true;
bool border = true, fill = true, clamp = true, overflowColor = true;
QColor borderColor, backgroundColor, fillColor, lineColor, outsideColor;
double minimum = 0, maximum = 100;
qint64 historyMs = 60000;
+189 -11
View File
@@ -2,6 +2,7 @@
#include "traypage.h"
#include "colorbutton.h"
#include "tooltip.h"
#include "widgets.h"
#include <QGroupBox>
#include <QSpinBox>
#include <QVBoxLayout>
@@ -9,11 +10,36 @@
#include <QColorDialog>
#include <QLabel>
#include <QSignalBlocker>
#include <QSlider>
#include <QGridLayout>
#include <QResizeEvent>
#include <QToolButton>
namespace {
QVariantMap trayValues(QSettings &settings)
{
QVariantMap values;
for (const auto &key : settings.allKeys()) if (key.startsWith("tray/")) values[key] = settings.value(key);
return values;
}
QVariantMap migrateBackground(QVariantMap values, const QColor &fallback)
{
if (values.value("tray/transparent").toBool()) {
QColor colour(values.value("tray/backgroundColor", fallback.name(QColor::HexArgb)).toString());
if (!colour.isValid()) colour = fallback;
colour.setAlpha(0); values["tray/backgroundColor"] = colour.name(QColor::HexArgb);
}
values.remove("tray/transparent");
return values;
}
}
TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent)
: QWidget(parent), m_sensors(sensors)
: TrayPage(trayValues(settings), sensors, parent) {}
TrayPage::TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QWidget *parent)
: QWidget(parent), m_values(migrateBackground(values, palette().color(QPalette::Window))), m_sensors(sensors)
{
for (const auto &key : settings.allKeys()) if (key.startsWith("tray/")) m_values[key] = settings.value(key);
auto *layout = new QVBoxLayout(this);
auto *form = new QFormLayout; form->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); layout->addLayout(form);
m_mode = new QComboBox; m_mode->setObjectName("trayDisplayMode");
@@ -56,10 +82,11 @@ TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget
};
m_border = check(colors, "Show border", "border", true);
auto *borderColor = colorRow(colors, "Border colour:", "borderColor", palette().color(QPalette::WindowText));
borderColor->setEnabled(m_border->isChecked()); connect(m_border, &QCheckBox::toggled, borderColor, &QWidget::setEnabled);
m_transparent = check(colors, "Transparent background", "transparent", false);
auto *background = colorRow(colors, "Background colour:", "backgroundColor", palette().color(QPalette::Window));
background->setDisabled(m_transparent->isChecked()); connect(m_transparent, &QCheckBox::toggled, background, &QWidget::setDisabled);
colors->setRowVisible(borderColor, m_border->isChecked());
connect(m_border, &QCheckBox::toggled, this, [colors, borderColor](bool enabled) {
colors->setRowVisible(borderColor, enabled);
});
colorRow(colors, "Background colour:", "backgroundColor", palette().color(QPalette::Window), true);
colorRow(colors, "Line / number colour:", "lineColor", QColor("#3daee9"));
layout->addWidget(appearance);
m_graphSettings = new QWidget; m_scaleLayout = new QFormLayout(m_graphSettings); m_scaleLayout->setContentsMargins(0, 0, 0, 0);
@@ -132,7 +159,10 @@ 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 *hoverTitle = new QLabel("Hover information"); hoverTitle->setObjectName("trayHoverTitle");
layout->addWidget(hoverTitle);
auto *hover = new QWidget; hover->setObjectName("trayHoverSettings");
auto *hoverForm = new QFormLayout(hover); hoverForm->setContentsMargins(24, 0, 0, 0);
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);
@@ -161,9 +191,11 @@ TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget
}
void TrayPage::load(const QVariantMap &values)
{
m_loading = true; m_values = values;
m_loading = true;
const auto migrated = migrateBackground(values, palette().color(QPalette::Window));
m_values = migrated;
for (const auto &reload : m_reload) reload();
m_values = values; loadScale(); m_loading = false;
m_values = migrated; loadScale(); m_loading = false;
}
QString TrayPage::scaleKey(const QString &suffix) const { return "tray/scales/" + metric().id + "/" + suffix; }
void TrayPage::loadScale()
@@ -174,7 +206,7 @@ void TrayPage::loadScale()
m_scaleLayout->setRowVisible(m_minimum, temperature || rate); m_scaleLayout->setRowVisible(m_maximum, temperature || frequency || rate);
for (auto *spin : {m_minimum, m_maximum}) { spin->setDecimals(0); spin->setRange(rate ? -300 : temperature ? -50 : 0, frequency ? 20000 : 300); spin->setSuffix(" " + unit); }
double maximum = frequency ? (metric().id == "cpu" ? cpuLimits().value("high", 5000).toDouble() : 3000) : rate ? 75 : 100;
const double low = m_values.value(scaleKey("minimum"), rate ? -75 : 0).toDouble();
const double low = m_values.value(scaleKey("minimum"), rate && !metric().id.startsWith("power/") ? -75 : 0).toDouble();
maximum = m_values.value(scaleKey("maximum"), maximum).toDouble();
m_minimum->setValue(low); m_maximum->setMinimum(m_minimum->value() + 1); m_maximum->setValue(maximum);
m_minimum->setMaximum(m_maximum->value() - 1);
@@ -187,7 +219,7 @@ TrayStyle TrayPage::iconStyle() const
};
TrayStyle s;
s.historyMs = m_history->currentData().toLongLong();
s.border = m_border->isChecked(); s.transparent = m_transparent->isChecked(); s.fill = m_fill->isChecked();
s.border = m_border->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));
s.backgroundColor = color("backgroundColor", palette().color(QPalette::Window));
@@ -197,3 +229,149 @@ TrayStyle TrayPage::iconStyle() const
else if (metric().unit == "°C" || metric().unit == "W") { s.minimum = m_minimum->value(); s.maximum = m_maximum->value(); }
return s;
}
TrayIconsPage::TrayIconsPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent)
: QWidget(parent), m_sensors(sensors)
{
auto *layout = new QVBoxLayout(this);
layout->setSizeConstraint(QLayout::SetNoConstraint);
auto *countRow = new QHBoxLayout;
m_count = new JumpSlider(Qt::Horizontal); m_count->setObjectName("trayIconCount");
m_count->setRange(1, 10); m_count->setTickInterval(1); m_count->setTickPosition(QSlider::TicksBelow);
auto *label = new QLabel("Number of icons:"); label->setBuddy(m_count);
auto *number = new QLabel; number->setMinimumWidth(fontMetrics().horizontalAdvance("10"));
countRow->addWidget(label); countRow->addWidget(m_count); countRow->addWidget(number);
layout->addLayout(countRow);
m_grid = new QGridLayout; layout->addLayout(m_grid); layout->addStretch();
connect(m_count, &QSlider::valueChanged, this, [this, number](int count) {
number->setNum(count); setCount(count);
if (!m_loading) Q_EMIT settingsChanged();
});
load(trayValues(settings)); number->setNum(m_count->value());
}
void TrayIconsPage::setCount(int count)
{
while (m_pages.size() < count) {
const int index = m_pages.size();
auto *group = sectionGroup({}, this);
group->setAccessibleName(QString("Icon %1").arg(index + 1));
auto *layout = new QVBoxLayout(group);
auto *heading = new QHBoxLayout;
auto *title = new QLabel(group->accessibleName()); title->setObjectName("trayIconTitle");
auto font = title->font(); font.setPointSizeF(font.pointSizeF() + 2); title->setFont(font);
heading->addWidget(title); heading->addSpacing(12);
auto *left = new QToolButton; left->setObjectName("moveIconLeft"); left->setArrowType(Qt::LeftArrow);
auto *right = new QToolButton; right->setObjectName("moveIconRight"); right->setArrowType(Qt::RightArrow);
left->setToolTip("Move to the previous icon position"); right->setToolTip("Move to the next icon position");
left->setAccessibleName("Move icon left"); right->setAccessibleName("Move icon right");
heading->addWidget(left); heading->addWidget(new QLabel("Move icon")); heading->addWidget(right); heading->addStretch();
layout->addLayout(heading);
m_moveLeft.append(left); m_moveRight.append(right);
connect(left, &QToolButton::clicked, this, [this, index] { moveIcon(index, index - 1); });
connect(right, &QToolButton::clicked, this, [this, index] { moveIcon(index, index + 1); });
auto *page = new TrayPage(QVariantMap{}, m_sensors);
layout->addWidget(page);
// Measure the full editor, including graph controls, before hiding them again.
auto *mode = page->findChild<QComboBox *>("trayDisplayMode");
mode->setCurrentIndex(mode->findData("graph"));
group->setMinimumWidth(group->sizeHint().width());
mode->setCurrentIndex(mode->findData("icon"));
m_groups.append(group); m_pages.append(page);
connect(page, &TrayPage::settingsChanged, this, [this] {
if (!m_loading) Q_EMIT settingsChanged();
});
}
for (int i = 0; i < m_groups.size(); ++i) {
m_groups[i]->setVisible(i < count);
m_moveLeft[i]->setEnabled(i > 0 && i < count);
m_moveRight[i]->setEnabled(i + 1 < count);
}
m_columns = 0; arrange();
}
void TrayIconsPage::moveIcon(int from, int to)
{
if (to < 0 || to >= m_count->value()) return;
const auto moving = m_pages[from]->draft();
m_pages[from]->load(m_pages[to]->draft());
m_pages[to]->load(moving);
Q_EMIT settingsChanged();
}
void TrayIconsPage::arrange()
{
if (m_groups.isEmpty()) return;
const int spacing = m_grid->horizontalSpacing();
const int available = contentsRect().width() - layout()->contentsMargins().left() - layout()->contentsMargins().right();
int cardWidth = 0;
for (int i = 0; i < m_count->value(); ++i) cardWidth = std::max(cardWidth, m_groups[i]->minimumWidth());
const int columns = std::clamp((available + spacing) / (cardWidth + spacing), 1, m_count->value());
if (columns == m_columns) return;
for (int column = 0; column < m_grid->columnCount(); ++column) m_grid->setColumnStretch(column, 0);
while (auto *item = m_grid->takeAt(0)) delete item;
for (int i = 0; i < m_count->value(); ++i) m_grid->addWidget(m_groups[i], i / columns, i % columns, Qt::AlignTop);
for (int column = 0; column < columns; ++column) m_grid->setColumnStretch(column, 1);
m_columns = columns;
}
void TrayIconsPage::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event); arrange();
}
QSize TrayIconsPage::minimumSizeHint() const
{
// Multiple columns must not prevent the containing scroll area shrinking to one.
int width = m_count->minimumSizeHint().width();
for (const auto *group : m_groups) width = std::max(width, group->minimumWidth());
const auto margins = layout()->contentsMargins();
return {width + margins.left() + margins.right(), layout()->minimumSize().height()};
}
QVariantMap TrayIconsPage::draft() const
{
QVariantMap result{{"tray/count", m_count->value()}};
for (int i = 0; i < m_count->value(); ++i) {
const auto values = m_pages[i]->draft();
const QString prefix = QString("tray/icons/%1/").arg(i + 1);
for (auto it = values.cbegin(); it != values.cend(); ++it) result[prefix + it.key().mid(5)] = it.value();
}
return result;
}
QVector<TrayConfiguration> TrayIconsPage::configurations() const
{
QVector<TrayConfiguration> result;
for (int i = 0; i < m_count->value(); ++i) {
const auto *page = m_pages[i];
result.append({page->mode(), page->metric(), page->iconStyle(), page->draft()});
}
return result;
}
void TrayIconsPage::load(const QVariantMap &values)
{
m_loading = true;
const int count = std::clamp(values.value("tray/count", 1).toInt(), 1, 10);
m_count->setValue(count); setCount(count);
for (int i = 0; i < count; ++i) {
QVariantMap icon;
const bool legacy = i == 0 && !values.contains("tray/count");
const QString prefix = legacy ? "tray/" : QString("tray/icons/%1/").arg(i + 1);
for (auto it = values.cbegin(); it != values.cend(); ++it)
if (it.key().startsWith(prefix)) icon["tray/" + it.key().mid(prefix.size())] = it.value();
// Normalise defaults and legacy values through the same single-icon editor.
TrayPage defaults(icon, m_sensors);
m_pages[i]->load(defaults.draft());
}
discardHidden(); m_loading = false;
}
void TrayIconsPage::discardHidden()
{
while (m_pages.size() > m_count->value()) {
m_moveLeft.removeLast(); m_moveRight.removeLast();
m_pages.removeLast(); delete m_groups.takeLast();
}
}
+40 -1
View File
@@ -9,11 +9,23 @@
#include <QCheckBox>
#include <QFormLayout>
#include <functional>
class QSlider;
class QGridLayout;
class QGroupBox;
class QToolButton;
struct TrayConfiguration {
QString mode;
Sensor metric;
TrayStyle style;
QVariantMap values;
};
class TrayPage : public QWidget {
Q_OBJECT
public:
TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
QString mode() const { return m_mode->currentData().toString(); }
Sensor metric() const { return m_sensors[m_metric->currentIndex()]; }
TrayStyle iconStyle() const;
@@ -32,5 +44,32 @@ private:
QDoubleSpinBox *m_minimum, *m_maximum;
QFormLayout *m_scaleLayout;
QWidget *m_graphSettings;
QCheckBox *m_border, *m_transparent, *m_fill, *m_overflowColor;
QCheckBox *m_border, *m_fill, *m_overflowColor;
};
class TrayIconsPage : public QWidget {
Q_OBJECT
public:
TrayIconsPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
QVariantMap draft() const;
QVector<TrayConfiguration> configurations() const;
void load(const QVariantMap &values);
void discardHidden();
QSize minimumSizeHint() const override;
Q_SIGNALS:
void settingsChanged();
protected:
void resizeEvent(QResizeEvent *event) override;
private:
void setCount(int count);
void arrange();
void moveIcon(int from, int to);
QVector<Sensor> m_sensors;
QVector<TrayPage *> m_pages;
QVector<QGroupBox *> m_groups;
QVector<QToolButton *> m_moveLeft, m_moveRight;
QSlider *m_count;
QGridLayout *m_grid;
bool m_loading = false;
int m_columns = 0;
};
+2 -1
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: MIT
#include "valuecontrol.h"
#include "widgets.h"
#include <QHBoxLayout>
#include <QSignalBlocker>
#include <algorithm>
ValueControl::ValueControl(QWidget *parent) : QWidget(parent),
m_slider(new QSlider(Qt::Horizontal, this)), m_number(new QSpinBox(this))
m_slider(new JumpSlider(Qt::Horizontal, this)), m_number(new QSpinBox(this))
{
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QApplication>
#include <QGroupBox>
#include <QProxyStyle>
#include <QSlider>
inline QGroupBox *sectionGroup(const QString &title, QWidget *parent = nullptr)
{
auto *group = new QGroupBox(title, parent);
group->setObjectName("settingsSection");
group->setProperty("sectionTitle", true);
group->setStyleSheet(QString("QGroupBox[sectionTitle=\"true\"] { font-size: %1pt; }")
.arg(group->font().pointSizeF() + 2));
return group;
}
class SliderStyle : public QProxyStyle {
public:
using QProxyStyle::QProxyStyle;
int styleHint(StyleHint hint, const QStyleOption *option = nullptr,
const QWidget *widget = nullptr, QStyleHintReturn *data = nullptr) const override
{
if (hint == SH_Slider_AbsoluteSetButtons)
return QProxyStyle::styleHint(hint, option, widget, data) | Qt::LeftButton;
if (hint == SH_Slider_PageSetButtons)
return QProxyStyle::styleHint(hint, option, widget, data) & ~Qt::LeftButton;
return QProxyStyle::styleHint(hint, option, widget, data);
}
};
class JumpSlider : public QSlider {
public:
explicit JumpSlider(Qt::Orientation orientation, QWidget *parent = nullptr) : QSlider(orientation, parent)
{
auto *proxy = new SliderStyle(QApplication::style()->name());
proxy->setParent(this); setStyle(proxy);
}
};
+70 -37
View File
@@ -3,6 +3,7 @@
#include "legend.h"
#include "fan.h"
#include "tooltip.h"
#include "widgets.h"
#include <QMessageBox>
#include <KAuth/Action>
#include <KAuth/ExecuteJob>
@@ -61,7 +62,6 @@ Window::Window() : m_settings("fedora-tools", "framework-laptop-tools"),
addPage(trayPage(), "Tray icon");
addPage(preferencesPage(), "Preferences");
m_savedTray = m_trayPage->draft(); m_savedPreferences = preferenceValues();
m_trayMetric = m_trayPage->metric(); m_trayMode = m_trayPage->mode(); m_trayStyle = m_trayPage->iconStyle();
for (const auto &key : {"keyboard", "power", "chargeLimit", "chargeWatts", "fan"}) m_savedControls[key] = controlValue(key);
m_pendingBar = new QWidget; m_pendingBar->setObjectName("pendingChanges");
auto *bar = new QHBoxLayout(m_pendingBar);
@@ -92,16 +92,7 @@ Window::Window() : m_settings("fedora-tools", "framework-laptop-tools"),
if (isVisible() && !m_sleeping && m_tabs->currentIndex() >= 1 && m_tabs->currentIndex() <= 3) refreshControls();
});
m_controlsTimer.start(2000);
m_tray = new QSystemTrayIcon(windowIcon(), this);
auto *menu = new QMenu(this);
menu->addAction("Open Framework Laptop Tools", this, [this] { show(); raise(); activateWindow(); });
menu->addSeparator();
menu->addAction("Quit", qApp, &QApplication::quit);
m_tray->setContextMenu(menu);
connect(m_tray, &QSystemTrayIcon::activated, this, [this](auto reason) {
if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { show(); raise(); activateWindow(); }
});
m_tray->show();
applyTray();
if (!QDBusConnection::systemBus().connect("org.freedesktop.login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager", "PrepareForSleep", this, SLOT(sleepChanged(bool))))
m_message->setText("Sleep notifications unavailable; sleep intervals cannot be marked on the battery graph.");
@@ -117,11 +108,13 @@ QGroupBox *Window::sensorGroup(const QString &title, Chart *chart, const QVector
auto *heading = new QHBoxLayout;
auto *label = new QLabel(title); label->setObjectName("chartTitle");
auto font = label->font(); font.setPointSizeF(font.pointSizeF() + 2); label->setFont(font);
heading->addStretch();
heading->addWidget(label);
if (title == "Fan speed" || title == "Battery") {
auto *details = new QLabel; details->setWordWrap(true); heading->addWidget(details, 1);
auto *details = new QLabel; details->setWordWrap(true); heading->addWidget(details);
if (title == "Fan speed") m_fanDetails = details; else m_batteryDetails = details;
} else heading->addStretch();
}
heading->addStretch();
layout->addLayout(heading);
QVector<Sensor> main, extra;
for (const auto &sensor : sensors) {
@@ -156,7 +149,7 @@ 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");
auto *history = new JumpSlider(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);
@@ -169,6 +162,8 @@ QWidget *Window::monitorPage()
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_powerChart = new Chart("W"); m_powerChart->setObjectName("powerChart");
layout->addWidget(sensorGroup("Power usage", m_powerChart, m_powerMonitor.sensors(), false));
m_fanChart = new Chart("RPM");
auto *fanGroup = sensorGroup("Fan speed", m_fanChart, {{"fan", "Fan speed", {}, "RPM"}}, false);
layout->addWidget(fanGroup);
@@ -176,7 +171,7 @@ QWidget *Window::monitorPage()
auto *batteryGroup = sensorGroup("Battery", m_batteryChart,
{{"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};
const QList<Chart *> charts{m_frequencyChart, m_powerChart, 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)
@@ -197,12 +192,12 @@ QWidget *Window::monitorPage()
QWidget *Window::lightingPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *keyboard = new QGroupBox("Keyboard"); auto *keys = new QFormLayout(keyboard);
auto *keyboard = sectionGroup("Keyboard"); auto *keys = new QFormLayout(keyboard);
m_keyboard = spin(0, 100, "%"); m_keyboard->setObjectName("keyboardBrightness");
keys->addRow("Keyboard brightness:", m_keyboard);
keys->addRow(note("The current firmware interface does not expose setting or detecting automatic brightness. If Auto is set using Fn+Space, any manual brightness here will be overridden."));
layout->addWidget(keyboard);
auto *power = new QGroupBox("Power button"); auto *lights = new QFormLayout(power);
auto *power = sectionGroup("Power button"); auto *lights = new QFormLayout(power);
m_power = spin(1, 100, "%"); m_power->setObjectName("powerBrightness");
m_powerAuto = new QCheckBox("Automatic brightness"); m_powerAuto->setObjectName("powerAuto");
lights->addRow(m_powerAuto); lights->addRow("Power-button brightness:", m_power);
@@ -243,7 +238,7 @@ QWidget *Window::coolingPage()
QWidget *Window::batteryPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *group = new QGroupBox("Battery"); auto *form = new QFormLayout(group);
auto *group = sectionGroup("Battery"); auto *form = new QFormLayout(group);
m_charge = spin(50, 100, "%"); m_charge->setValue(100); m_charge->setObjectName("chargeLimit");
form->addRow("Limit battery:", m_charge);
m_watts = spin(0, 75, " W"); m_watts->setSpecialValueText("Firmware default"); m_watts->setObjectName("chargeWatts");
@@ -273,27 +268,58 @@ QWidget *Window::preferencesPage()
QWidget *Window::trayPage()
{
QVector<Sensor> sensors{{"cpu-usage", "CPU usage", {}, "%"}};
sensors += m_frequencies; sensors += m_temperatures;
sensors += m_frequencies; sensors += m_temperatures; sensors += m_powerMonitor.sensors();
sensors += QVector<Sensor>{{"battery", "Battery charge", {}, "%"}, {"battery-rate", "Battery rate", {}, "W"}};
m_trayPage = new TrayPage(m_settings, sensors);
connect(m_trayPage, &TrayPage::settingsChanged, this, &Window::updatePendingBar);
m_trayPage = new TrayIconsPage(m_settings, sensors);
connect(m_trayPage, &TrayIconsPage::settingsChanged, this, &Window::updatePendingBar);
return m_trayPage;
}
void Window::applyTray()
{
const auto configurations = m_trayPage->configurations();
while (m_trays.size() > configurations.size()) {
const auto tray = m_trays.takeLast();
delete tray.icon;
delete tray.menu;
}
for (int i = 0; i < configurations.size(); ++i) {
if (i == m_trays.size()) {
auto *icon = new QSystemTrayIcon(windowIcon(), this);
// KDE's native tray backend owns its menu: sharing one across icons
// makes their cleanup delete the same native menu more than once.
auto *menu = new QMenu(this);
menu->addAction("Open Framework Laptop Tools", this, [this] { show(); raise(); activateWindow(); });
menu->addSeparator();
menu->addAction("Quit", qApp, &QApplication::quit);
icon->setContextMenu(menu);
connect(icon, &QSystemTrayIcon::activated, this, [this](auto reason) {
if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { show(); raise(); activateWindow(); }
});
m_trays.append({icon, menu, configurations[i]}); icon->show();
} else m_trays[i].config = configurations[i];
}
updateTray();
}
void Window::updateTray()
{
const auto metric = m_trayMetric;
if (m_trayMode == "icon") m_tray->setIcon(windowIcon());
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));
const auto now = QDateTime::currentMSecsSinceEpoch();
const auto temperatures = tooltipSensors(m_temperatures);
for (const auto &tray : m_trays) {
const auto &config = tray.config;
const auto &metric = config.metric;
if (config.mode == "icon") tray.icon->setIcon(windowIcon());
else {
const double since = now - (config.mode == "graph" ? config.style.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 if (metric.id.startsWith("power/")) history = m_powerChart->history(metric.id, since);
else history = (metric.unit == "MHz" ? m_frequencyChart : m_temperatureChart)->history(metric.id, since);
tray.icon->setIcon(telemetryIcon(config.mode == "graph", history, metric.unit, config.style, now));
}
tray.icon->setToolTip(trayTooltip(config.values, m_values, temperatures, m_batteryText, m_topAppText));
}
m_tray->setToolTip(trayTooltip(m_savedTray, m_values, tooltipSensors(m_temperatures), m_batteryText, m_topAppText));
}
QVariantMap Window::controlValue(const QString &key) const
{
@@ -407,7 +433,7 @@ QString Window::saveLocalSettings()
if (m_settings.status() != QSettings::NoError) return "Could not save application settings.";
m_savedTray = tray; m_savedPreferences = preferences;
m_fastTimer.start(preferences["sampling/fast"].toInt()); m_batteryTimer.start(preferences["sampling/battery"].toInt());
m_trayMetric = m_trayPage->metric(); m_trayMode = m_trayPage->mode(); m_trayStyle = m_trayPage->iconStyle(); updateTray();
m_trayPage->discardHidden(); applyTray();
return {};
}
void Window::finishSave()
@@ -493,7 +519,7 @@ void Window::sample()
{
if (m_sleeping) return;
m_values.clear();
m_cpuPage->refreshStatus();
if (m_cpuPage->isVisible()) m_cpuPage->refreshStatus();
auto sampleSensors = [&](const QVector<Sensor> &sensors, Chart *chart) {
QMap<QString, double> values;
for (const auto &sensor : sensors) {
@@ -503,6 +529,9 @@ void Window::sample()
chart->sample(values, m_fastTimer.interval());
};
sampleSensors(m_frequencies, m_frequencyChart); sampleSensors(m_temperatures, m_temperatureChart);
const auto power = m_powerMonitor.sample(m_fastTimer.interval());
m_powerChart->sample(power, m_fastTimer.interval());
m_values.insert(power);
const QString ec = ecHwmon();
const auto rpm = readNumber(ec + "/fan1_input");
const auto mode = readNumber(ec + "/pwm1_enable");
@@ -518,7 +547,10 @@ void Window::sample()
const auto battery = batteryStatus();
const auto usage = m_cpuUsage.sample(readText("/proc/stat"));
if (usage) m_values["cpu-usage"] = *usage;
if (tooltipEnabled(m_savedTray, "cpu") && tooltipAppCount(m_savedTray) > 0) {
const bool showApps = std::any_of(m_trays.cbegin(), m_trays.cend(), [](const auto &tray) {
return tooltipEnabled(tray.config.values, "cpu") && tooltipAppCount(tray.config.values) > 0;
});
if (showApps) {
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);
@@ -551,9 +583,10 @@ void Window::sampleBattery()
void Window::sleepChanged(bool sleeping)
{
m_sleeping = sleeping;
for (auto *chart : {m_frequencyChart, m_temperatureChart, m_batteryChart, m_fanChart})
for (auto *chart : {m_frequencyChart, m_powerChart, m_temperatureChart, m_batteryChart, m_fanChart})
chart->setPowerState(sleeping, sleeping || chart != m_batteryChart ? std::nullopt : onAcPower());
m_cpuUsage.reset();
m_powerMonitor.reset();
m_processUsage.reset(); m_topAppText.clear();
if (!sleeping) { sample(); sampleBattery(); }
}
+8 -6
View File
@@ -5,6 +5,7 @@
#include "tray.h"
#include "traypage.h"
#include "processusage.h"
#include "power.h"
#include <QMainWindow>
#include <QSettings>
#include <QTimer>
@@ -15,6 +16,7 @@
class QGroupBox;
class QTabWidget;
class QPushButton;
class QMenu;
class Window : public QMainWindow {
Q_OBJECT
@@ -30,6 +32,7 @@ private:
QWidget *preferencesPage();
QWidget *trayPage();
void updateTray();
void applyTray();
void sample();
void sampleBattery();
void request(const QVariantMap &arguments, bool inspect = false);
@@ -48,18 +51,20 @@ private:
QTimer m_fastTimer, m_batteryTimer, m_controlsTimer;
QTabWidget *m_tabs;
QVector<Sensor> m_temperatures, m_frequencies;
PowerMonitor m_powerMonitor;
QMap<QString, double> m_values;
CpuUsage m_cpuUsage;
ProcessUsage m_processUsage;
QStringList m_topAppText;
Samples m_usageHistory;
Chart *m_frequencyChart, *m_temperatureChart, *m_batteryChart, *m_fanChart;
Chart *m_frequencyChart, *m_powerChart, *m_temperatureChart, *m_batteryChart, *m_fanChart;
QLabel *m_batteryDetails, *m_fanDetails;
QLabel *m_message, *m_fanReadout, *m_firmwareReadout;
QString m_batteryText;
QVector<QWidget *> m_controlPages;
CpuPage *m_cpuPage;
QSystemTrayIcon *m_tray;
struct TrayIcon { QSystemTrayIcon *icon; QMenu *menu; TrayConfiguration config; };
QVector<TrayIcon> m_trays;
ValueControl *m_keyboard, *m_power, *m_charge, *m_watts, *m_duty;
QCheckBox *m_powerAuto;
QLabel *m_fanWarning;
@@ -75,11 +80,8 @@ private:
QWidget *m_pendingBar = nullptr;
QPushButton *m_saveButton, *m_undoButton;
bool m_saving = false;
Sensor m_trayMetric;
QString m_trayMode;
TrayStyle m_trayStyle;
QComboBox *m_fanMode;
TrayPage *m_trayPage;
TrayIconsPage *m_trayPage;
int m_chargeLimit = 100;
bool m_chargeOverride = false;
bool m_busy = false;
@@ -2,6 +2,7 @@
#include "hardware.h"
#include "fan.h"
#include "cpu.h"
#include "power.h"
#include <QTest>
#include <QTemporaryDir>
#include <QDir>
@@ -14,6 +15,76 @@ class HardwareTest : public QObject {
QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write(value), value.size());
}
private Q_SLOTS:
void energyCounters() {
EnergyCounter counter;
const quint64 range = 262143328850ULL;
QVERIFY(!counter.sample(1000000, range, 0, 3000));
QCOMPARE(counter.sample(6000000, range, 1000, 3000), std::optional<double>(5));
QCOMPARE(counter.sample(16000000, range, 3000, 3000), std::optional<double>(5));
QVERIFY(!counter.sample(100, range, 4000, 3000)); // Reset, not a wrap.
QCOMPARE(counter.sample(100, range, 5000, 3000), std::optional<double>(0));
QVERIFY(!counter.sample(200, range, 10000, 3000)); // Long gaps establish a new baseline.
QCOMPARE(counter.sample(1000200, range, 11000, 3000), std::optional<double>(1));
EnergyCounter wrapped;
QVERIFY(!wrapped.sample(range - 3000000, range, 0, 3000));
QCOMPARE(wrapped.sample(2000000, range, 1000, 3000), std::optional<double>(5));
QVERIFY(!wrapped.sample(100, 100000000, 2000, 3000)); // Changed range.
QVERIFY(!wrapped.sample(100, 0, 3000, 3000));
QVERIFY(!wrapped.sample(range + 1, range, 4000, 3000));
EnergyCounter large;
const quint64 base = 1ULL << 60;
QVERIFY(!large.sample(base, base * 2, 0, 3000));
QCOMPARE(large.sample(base + 1000, base * 2, 1000, 3000), std::optional<double>(.001));
}
void powerDiscoveryAndSampling() {
QTemporaryDir root;
const QString rapl = root.path() + "/class/powercap/intel-rapl:0/";
const QString mmio = root.path() + "/class/powercap/intel-rapl-mmio:0/";
for (const auto &path : {rapl, mmio}) {
put(path + "name", "package-0"); put(path + "energy_uj", "1000000");
put(path + "max_energy_range_uj", "262143328850");
}
const QString fan = root.path() + "/class/hwmon/hwmon9/";
put(fan + "name", "power_meter"); put(fan + "power1_input", "250000"); put(fan + "power1_average", "500000");
const QString acpiFan = root.path() + "/class/hwmon/hwmon10/";
put(acpiFan + "name", "acpi_fan"); put(acpiFan + "power1_input", "0");
PowerMonitor monitor(root.path()); QCOMPARE(monitor.sensors().size(), 2);
QCOMPARE(monitor.sensors()[0].name, QString("CPU package"));
QCOMPARE(monitor.sensors()[0].path, rapl + "energy_uj");
const QString cpu = monitor.sensors()[0].id, fanId = monitor.sensors()[1].id;
auto values = monitor.sample(1000, 0);
QVERIFY(!values.contains(cpu)); QCOMPARE(values.value(fanId), .25);
put(rapl + "energy_uj", "6000000");
values = monitor.sample(1000, 1000); QCOMPARE(values.value(cpu), 5.);
put(rapl + "energy_uj", "unavailable"); QVERIFY(!monitor.sample(1000, 2000).contains(cpu));
put(rapl + "energy_uj", "11000000"); QVERIFY(!monitor.sample(1000, 3000).contains(cpu));
put(rapl + "energy_uj", "16000000"); QCOMPARE(monitor.sample(1000, 4000).value(cpu), 5.);
monitor.reset(); QVERIFY(!monitor.sample(1000, 5000).contains(cpu));
put(fan + "power1_input", "0"); QCOMPARE(monitor.sample(1000, 6000).value(fanId), 0.);
QTemporaryDir fallback;
const QString fallbackZone = fallback.path() + "/class/powercap/intel-rapl-mmio:0/";
put(fallbackZone + "name", "package-0"); put(fallbackZone + "energy_uj", "0");
PowerMonitor mmioOnly(fallback.path()); QCOMPARE(mmioOnly.sensors().size(), 1);
QCOMPARE(mmioOnly.sensors()[0].id, cpu);
QVERIFY(mmioOnly.sample(1000, 0).isEmpty()); // Missing range is not a zero reading.
}
void platformPowerIsExcluded() {
for (const auto &prefix : {"intel-rapl:", "intel-rapl-mmio:"}) {
QTemporaryDir root;
const QStringList names{"package-0", "core", "uncore", "dram", "psys", "psys-0"};
for (int i = 0; i < names.size(); ++i) {
const QString path = root.path() + "/class/powercap/" + prefix + QString::number(i) + "/";
put(path + "name", names[i].toUtf8()); put(path + "energy_uj", "0");
put(path + "max_energy_range_uj", "1000000");
}
PowerMonitor monitor(root.path());
QStringList discovered;
for (const auto &sensor : monitor.sensors()) discovered.append(sensor.name);
QCOMPARE(discovered, QStringList({"CPU package", "CPU cores", "Uncore", "Memory (RAPL)"}));
QVERIFY(monitor.sample(1000, 0).isEmpty());
QCOMPARE(monitor.sample(1000, 1000).size(), 4);
}
}
void modelGate() {
QTemporaryDir root;
QVERIFY(!compatibilityError(root.path()).isEmpty());
+426 -22
View File
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: MIT
#include "window.h"
#include <QMenu>
#include <QPainter>
#include <QMouseEvent>
#include <QTest>
#include <QTemporaryDir>
#include <QTabWidget>
@@ -21,6 +24,11 @@
#include "processusage.h"
#include <QFile>
#include <QDir>
#include <QGroupBox>
#include <QScrollArea>
#include <QScrollBar>
#include "widgets.h"
#include <QColorDialog>
class WindowTest : public QObject {
Q_OBJECT
@@ -99,9 +107,24 @@ private Q_SLOTS:
auto *outside = page.findChild<QComboBox *>("trayOutside");
QVERIFY(graph->isHidden());
mode->setCurrentIndex(mode->findData("graph")); QVERIFY(graph->isVisible()); QVERIFY(overflow->isVisible());
auto *border = page.findChild<QCheckBox *>("border");
auto *borderColour = page.findChild<ColorButton *>("borderColor");
auto *colours = qobject_cast<QFormLayout *>(borderColour->parentWidget()->layout());
auto *borderLabel = colours->labelForField(borderColour);
const auto bordered = page.draft();
border->setChecked(false);
QVERIFY(borderColour->isHidden()); QVERIFY(borderLabel->isHidden());
QCOMPARE(page.draft().value("tray/borderColor"), bordered.value("tray/borderColor"));
page.load(bordered); QVERIFY(borderColour->isVisible()); QVERIFY(borderLabel->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 *hoverTitle = page.findChild<QLabel *>("trayHoverTitle");
auto *hoverSettings = page.findChild<QWidget *>("trayHoverSettings");
QVERIFY(hoverTitle); QVERIFY(hoverSettings);
QVERIFY(!qobject_cast<QGroupBox *>(hoverSettings));
QCOMPARE(hoverSettings->layout()->contentsMargins().left(), 24);
QVERIFY(cpu->mapTo(&page, QPoint()).x() > hoverTitle->mapTo(&page, QPoint()).x());
auto *app = page.findChild<QSpinBox *>("hover/topApps");
QCOMPARE(app->minimum(), 0); QCOMPARE(app->maximum(), 3);
const auto saved = page.draft();
@@ -117,6 +140,31 @@ private Q_SLOTS:
QVERIFY(!migrated.draft().contains("tray/hover/topApp"));
QCOMPARE(tooltipAppCount({{"tray/hover/topApps", 3}, {"tray/hover/topApp", false}}), 3);
}
void backgroundOpacity() {
const QVector<Sensor> sensors{{"cpu-usage", "CPU", {}, "%"}};
TrayPage page(QVariantMap{{"tray/transparent", true}, {"tray/backgroundColor", "#ff123456"}, {"tray/mode", "number"}}, sensors);
QVERIFY(!page.findChild<QCheckBox *>("transparent"));
QVERIFY(!page.draft().contains("tray/transparent"));
QCOMPARE(page.iconStyle().backgroundColor, QColor(0x12, 0x34, 0x56, 0));
const auto transparent = page.draft(); page.show();
bool alphaAvailable = false;
QTimer::singleShot(0, &page, [&] {
auto *dialog = qobject_cast<QColorDialog *>(QApplication::activeModalWidget());
if (!dialog) return;
alphaAvailable = dialog->testOption(QColorDialog::ShowAlphaChannel);
dialog->setCurrentColor(QColor(20, 40, 60, 128)); dialog->accept();
});
page.findChild<ColorButton *>("backgroundColor")->click(); QVERIFY(alphaAvailable);
QCOMPARE(page.iconStyle().backgroundColor, QColor(20, 40, 60, 128));
const auto half = page.draft();
page.load(transparent); QCOMPARE(page.iconStyle().backgroundColor.alpha(), 0);
page.load(half); QCOMPARE(page.iconStyle().backgroundColor.alpha(), 128);
auto style = page.iconStyle(); style.border = false; style.fill = false;
const auto image = telemetryIcon(true, {}, "%", style).pixmap(64, 64).toImage();
QCOMPARE(image.pixelColor(1, 1).alpha(), 128);
TrayPage oldOpaque(QVariantMap{{"tray/transparent", false}, {"tray/backgroundColor", "#ff123456"}}, sensors);
QCOMPARE(oldOpaque.iconStyle().backgroundColor.alpha(), 255);
}
void liveProcessSampling() {
CpuUsage cpu; ProcessUsage processes;
cpu.sample(readText("/proc/stat"));
@@ -131,12 +179,152 @@ private Q_SLOTS:
previous = app.percent;
}
}
void multipleTrayDraftsAndLayout() {
QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat);
settings.setValue("tray/mode", "number"); settings.setValue("tray/hover/topApp", true);
const QVector<Sensor> sensors{{"cpu-usage", "CPU usage", {}, "%"}};
TrayIconsPage page(settings, sensors); page.show();
auto *count = page.findChild<QSlider *>("trayIconCount");
QCOMPARE(count->minimum(), 1); QCOMPARE(count->maximum(), 10);
QCOMPARE(page.configurations().first().mode, QString("number"));
QCOMPARE(tooltipAppCount(page.configurations().first().values), 1);
const auto original = page.draft();
QVERIFY(!original.contains("tray/mode"));
QCOMPARE(original.value("tray/icons/1/mode").toString(), QString("number"));
count->setValue(3);
auto editors = page.findChildren<TrayPage *>(); QCOMPARE(editors.size(), 3);
QCOMPARE(page.configurations()[1].mode, QString("icon"));
auto *secondMode = editors[1]->findChild<QComboBox *>("trayDisplayMode");
secondMode->setCurrentIndex(secondMode->findData("graph"));
editors[1]->findChild<QComboBox *>("trayHistory")->setCurrentIndex(0);
count->setValue(1);
QCOMPARE(page.draft(), original); // Hidden cards do not get serialised.
count->setValue(3);
QCOMPARE(page.configurations()[1].mode, QString("graph"));
QCOMPARE(page.configurations()[1].style.historyMs, qint64(10000));
const auto three = page.draft();
page.load(original); count->setValue(3);
QCOMPARE(page.configurations()[1].mode, QString("icon")); // Undo discards unsaved additions.
page.load(three);
count->setValue(1); page.discardHidden(); count->setValue(3);
QCOMPARE(page.configurations()[1].mode, QString("icon")); // Successful save forgets removed cards.
page.load(three);
const auto cards = page.findChildren<QGroupBox *>(QString(), Qt::FindDirectChildrenOnly);
QCOMPARE(cards.size(), 3);
const int width = cards[0]->minimumWidth();
page.resize(width * 3 + 100, 1600);
QTRY_COMPARE(cards[0]->y(), cards[1]->y());
QCOMPARE(cards[1]->y(), cards[2]->y()); QVERIFY(cards[1]->x() > cards[0]->x());
page.resize(width + 40, 3000);
QTRY_VERIFY(cards[1]->y() > cards[0]->y()); QCOMPARE(cards[1]->x(), cards[0]->x());
count->setValue(10); QCOMPARE(page.configurations().size(), 10);
QCOMPARE(page.draft().value("tray/icons/10/mode").toString(), QString("icon"));
for (const auto &key : settings.allKeys()) settings.remove(key);
const auto saved = page.draft();
for (auto it = saved.cbegin(); it != saved.cend(); ++it) settings.setValue(it.key(), it.value());
TrayIconsPage restored(settings, sensors);
QCOMPARE(restored.draft(), saved); QCOMPARE(restored.configurations().size(), 10);
const QString shot = qEnvironmentVariable("FRAMEWORK_TRAY_MULTI_SCREENSHOT");
if (!shot.isEmpty()) {
page.load(three); page.resize(width * 2 + 70, 1500); QTest::qWait(50);
QVERIFY(page.grab().save(shot));
}
}
void multipleLiveTrayIcons() {
QTemporaryDir root; qputenv("XDG_CONFIG_HOME", root.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, root.path());
QSettings settings("fedora-tools", "framework-laptop-tools");
settings.setValue("tray/mode", "number");
Window window; window.show();
auto *page = window.findChild<TrayIconsPage *>();
auto *count = window.findChild<QSlider *>("trayIconCount");
auto *save = window.findChild<QPushButton *>("saveAll");
auto *undo = window.findChild<QPushButton *>("undoAll");
auto icons = window.findChildren<QSystemTrayIcon *>(); QCOMPARE(icons.size(), 1);
auto *first = icons[0];
count->setValue(2); QCOMPARE(window.findChildren<QSystemTrayIcon *>().size(), 1);
auto *tabs = window.findChild<QTabWidget *>(); tabs->setCurrentIndex(5);
const auto cards = page->findChildren<QGroupBox *>(QString(), Qt::FindDirectChildrenOnly);
const int cardWidth = cards[0]->minimumWidth();
window.resize(cardWidth * 2 + 150, 830);
QTRY_COMPARE(cards[0]->y(), cards[1]->y());
window.resize(cardWidth + 150, 830);
QTRY_VERIFY(cards[1]->y() > cards[0]->y());
auto *scroll = qobject_cast<QScrollArea *>(tabs->widget(5));
QTRY_COMPARE(scroll->horizontalScrollBar()->maximum(), 0);
QVERIFY(scroll->verticalScrollBar()->maximum() > 0);
auto *second = page->findChildren<TrayPage *>()[1];
second->findChild<QCheckBox *>("hover/cpu")->setChecked(false);
QTRY_VERIFY(save->isEnabled()); save->click();
icons = window.findChildren<QSystemTrayIcon *>(); QCOMPARE(icons.size(), 2); QCOMPARE(icons[0], first);
QVERIFY(icons[0]->toolTip().startsWith("CPU usage:"));
QVERIFY(!icons[1]->toolTip().startsWith("CPU usage:"));
QVERIFY(icons[0]->contextMenu() != icons[1]->contextMenu());
QCOMPARE(icons[0]->contextMenu()->actions().size(), icons[1]->contextMenu()->actions().size());
QCOMPARE(settings.value("tray/count").toInt(), 2);
QVERIFY(!settings.contains("tray/mode"));
const auto ordered = page->draft();
cards[0]->findChild<QToolButton *>("moveIconRight")->click();
QVERIFY(icons[0]->toolTip().startsWith("CPU usage:")); // Move is still only a draft.
QTRY_VERIFY(save->isEnabled()); save->click();
QVERIFY(!icons[0]->toolTip().startsWith("CPU usage:"));
QVERIFY(icons[1]->toolTip().startsWith("CPU usage:"));
QVERIFY(!settings.value("tray/icons/1/hover/cpu").toBool());
cards[0]->findChild<QToolButton *>("moveIconRight")->click();
QTRY_VERIFY(undo->isEnabled()); undo->click();
QVERIFY(!page->configurations()[0].values.value("tray/hover/cpu").toBool());
cards[0]->findChild<QToolButton *>("moveIconRight")->click();
QCOMPARE(page->draft(), ordered);
QTRY_VERIFY(save->isEnabled()); save->click();
count->setValue(1); QTRY_VERIFY(undo->isEnabled()); undo->click(); QCOMPARE(count->value(), 2);
QVERIFY(!page->findChildren<TrayPage *>()[1]->findChild<QCheckBox *>("hover/cpu")->isChecked());
count->setValue(1); QTRY_VERIFY(save->isEnabled()); save->click();
QCOMPARE(window.findChildren<QSystemTrayIcon *>().size(), 1);
for (const auto &key : settings.allKeys()) QVERIFY(!key.startsWith("tray/icons/2/"));
count->setValue(2);
QVERIFY(page->findChildren<TrayPage *>()[1]->findChild<QCheckBox *>("hover/cpu")->isChecked());
QCOMPARE(page->configurations()[1].mode, QString("icon"));
}
void moveTrayConfigurations() {
QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat);
TrayIconsPage page(settings, {{"cpu-usage", "CPU", {}, "%"}});
auto *count = page.findChild<QSlider *>("trayIconCount"); count->setValue(3);
const auto cards = page.findChildren<QGroupBox *>(QString(), Qt::FindDirectChildrenOnly);
auto *first = cards[0]->findChild<TrayPage *>();
auto *last = cards[2]->findChild<TrayPage *>();
first->findChild<QComboBox *>("trayDisplayMode")->setCurrentIndex(1);
first->findChild<QComboBox *>("trayHistory")->setCurrentIndex(0);
first->findChild<QCheckBox *>("border")->setChecked(false);
last->findChild<QComboBox *>("trayDisplayMode")->setCurrentIndex(2);
last->findChild<QSpinBox *>("hover/topApps")->setValue(3);
const auto firstValues = first->draft(), lastValues = last->draft();
const auto original = page.draft();
QVERIFY(!cards[0]->findChild<QToolButton *>("moveIconLeft")->isEnabled());
QVERIFY(!cards[2]->findChild<QToolButton *>("moveIconRight")->isEnabled());
QSignalSpy changed(&page, &TrayIconsPage::settingsChanged);
cards[2]->findChild<QToolButton *>("moveIconLeft")->click();
QCOMPARE(page.configurations()[1].values, lastValues); QCOMPARE(changed.count(), 1);
cards[0]->findChild<QToolButton *>("moveIconRight")->click();
QCOMPARE(page.configurations()[0].values, lastValues);
QCOMPARE(page.configurations()[1].values, firstValues);
// Crossing a grid row still exchanges adjacent logical positions.
cards[1]->findChild<QToolButton *>("moveIconRight")->click();
QCOMPARE(page.configurations()[2].values, firstValues);
count->setValue(2);
QVERIFY(!cards[1]->findChild<QToolButton *>("moveIconRight")->isEnabled());
count->setValue(3); QCOMPARE(page.configurations()[2].values, firstValues);
page.load(original); QCOMPARE(page.draft(), original);
count->setValue(1);
QVERIFY(!cards[0]->findChild<QToolButton *>("moveIconRight")->isEnabled());
QVERIFY(!cards[0]->findChild<QToolButton *>("moveIconLeft")->isEnabled());
}
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[0].lower(), columns[0].mean); QVERIFY(columns[0].compressed);
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.
@@ -153,17 +341,70 @@ private Q_SLOTS:
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);
QVERIFY(!column.compressed);
QCOMPARE(column.lower(), column.minimum);
QVERIFY(std::abs(column.minimum - (column.begin * 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.size(), 2);
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.
QCOMPARE(columns[0].end, 2.); QCOMPARE(columns[1].begin, 4.); // Gaps stay separate even in 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 columnThicknessIsVerticalOnly() {
QCOMPARE(columnBand(5, 6, 10, 10), QRectF(5, 9, 1, 2));
QCOMPARE(columnBand(5, 6, 10, 11, 2, 2), QRectF(5, 9.5, 1, 2));
QCOMPARE(columnBand(5, 6, 10, 50), QRectF(5, 10, 1, 40));
for (double ratio : {1., 1.25, 1.5, 2.}) for (double bottom : {10., 11., 20.}) {
QImage image(qRound(32 * ratio), qRound(32 * ratio), QImage::Format_ARGB32_Premultiplied);
image.setDevicePixelRatio(ratio); image.fill(Qt::transparent);
{
QPainter painter(&image);
painter.fillRect(columnBand(5 / ratio, 6 / ratio, 10, bottom, 2, ratio), Qt::green);
}
int pixels = 0;
for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x) {
if (!image.pixelColor(x, y).alpha()) continue;
QCOMPARE(x, 5); ++pixels; // Thickness never leaks into either neighbour.
}
QVERIFY(pixels >= std::ceil(2 * ratio));
}
}
void sparseSlopeStaysInItsColumns() {
TrayStyle style; style.border = false; style.fill = false;
style.backgroundColor = Qt::black; style.lineColor = Qt::green; style.historyMs = 6200;
// Two samples span exactly two raster columns and most of the graph height.
const auto image = telemetryIcon(true, {{1000, 10}, {1200, 90}}, "%", style, 6200).pixmap(64, 64).toImage();
int pixels = 0;
for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x) {
if (image.pixelColor(x, y) != QColor(Qt::green)) continue;
QVERIFY(x == 11 || x == 12); ++pixels;
}
QVERIFY(pixels > 40);
}
void peakHeightSurvivesPixelPhase() {
QVector<QPointF> points;
for (int i = 0; i <= 1000; ++i) points.append({double(i), i == 500 ? 100. : 10.});
for (int phase = 0; phase <= 32; ++phase) {
const double first = 200 + phase / 32. * 7.5;
const auto columns = timeAverages(points.cbegin(), points.cend(), first, first + 600, 80,
[](const QPointF &, const QPointF &) { return true; });
QImage image(80, 120, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent);
{
QPainter painter(&image);
for (const auto &column : columns)
painter.fillRect(columnBand(column.index, column.index + 1, 110 - column.maximum,
110 - column.lower()), Qt::green);
}
int top = image.height();
for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x)
if (image.pixelColor(x, y).alpha()) top = std::min(top, y);
QCOMPARE(top, 10); // The peak's rasterised height, not merely its numeric maximum.
}
}
void dayHistoryAndStretch() {
Chart chart("MHz"); chart.addSeries("hidden");
const qint64 start = 100000000;
@@ -187,6 +428,7 @@ private Q_SLOTS:
}
void monitorHistorySettings() {
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path());
{
Window window;
auto *slider = window.findChild<QSlider *>("monitorHistory"); QVERIFY(slider);
@@ -208,10 +450,10 @@ private Q_SLOTS:
TrayPage tray(settings, {{"cpu-usage", "CPU usage", {}, "%"}});
QCOMPARE(tray.iconStyle().historyMs, qint64(60000)); // Retired choices use the new default.
}
void denseHistorySoftensPeaks() {
void denseHistoryShowsPeakColumns() {
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.
// A half-second spike fills its column up to the maximum.
for (int i = 0; i <= 172800; ++i)
chart.sample({{"sensor", i == 43200 ? 100. : 10.}}, 500, start + i * 500);
QCOMPARE(chart.history("sensor").size(), 172801);
@@ -222,8 +464,7 @@ private Q_SLOTS:
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;
peak |= colour == chart.color("sensor");
}
QVERIFY(peak);
chart.setHistoryWindow(300000, true);
@@ -236,12 +477,19 @@ private Q_SLOTS:
QCOMPARE(freq.minimum, 0.); QCOMPARE(freq.maximum, 6000.); QCOMPARE(freq.step, 2000.);
const auto rate = AxisTicks::covering(-13, 42, 4);
QCOMPARE(rate.minimum, -20.); QCOMPARE(rate.maximum, 60.); QCOMPARE(rate.step, 20.);
QCOMPARE(timeTickStep(3600000, 6), 600000.);
QCOMPARE(timeTickStep(3600000, 6), 720000.); // 0.2 h, not a repeating decimal.
QCOMPARE(timeTickStep(3600000, 3), 1800000.);
QCOMPARE(ageLabel(7200000), QString("2h"));
QCOMPARE(ageLabel(1200000), QString("20min"));
QCOMPARE(ageLabel(5000), QString("5s"));
QCOMPARE(ageLabel(0), QString("Now"));
QCOMPARE(timeTickStep(240000, 8), 30000.);
QCOMPARE(timeTickStep(60000, 8), 12000.); // 0.2 min.
QCOMPARE(ageLabel(7200000, 7200000), QString("2h"));
QCOMPARE(ageLabel(1800000, 7200000), QString("0.5h"));
QCOMPARE(ageLabel(1200000, 1200000), QString("20min"));
QCOMPARE(ageLabel(210000, 240000), QString("3.5min"));
QCOMPARE(ageLabel(150000, 240000), QString("2.5min"));
QCOMPARE(ageLabel(90000, 240000), QString("1.5min"));
QCOMPARE(ageLabel(30000, 240000), QString("0.5min"));
QCOMPARE(ageLabel(5000, 10000), QString("5s"));
QCOMPARE(ageLabel(0, 240000), QString("Now"));
}
void historyHoverAndSleep() {
Chart chart("%"); chart.addSeries("battery", "Charge level"); chart.setSelected("battery", true);
@@ -266,6 +514,64 @@ private Q_SLOTS:
chart.sample({}, 30000, historyRetentionMs + 230000);
QVERIFY(!chart.readingAt(106000).contains("Asleep")); // Annotations expire with samples.
}
void samplingIntervalChanges_data() {
QTest::addColumn<int>("before"); QTest::addColumn<int>("after");
QTest::newRow("battery-faster") << 60000 << 15000;
QTest::newRow("battery-slower") << 15000 << 60000;
QTest::newRow("fast-sensors-faster") << 2000 << 500;
QTest::newRow("fast-sensors-slower") << 500 << 2000;
}
void samplingIntervalChanges() {
QFETCH(int, before); QFETCH(int, after);
Chart changed("%"), reference("%");
for (auto *chart : {&changed, &reference}) {
chart->resize(850, 240); chart->addSeries("battery", "Battery");
chart->setSelected("battery", true); chart->setHistoryWindow(300000, false);
for (int i = 0; i < 4; ++i) chart->sample({{"battery", 50}}, before, i * before);
}
const qint64 now = 3 * before + after;
changed.sample({{"battery", 50}}, after, now);
reference.sample({{"battery", 50}}, std::max(before, after), now);
const auto history = changed.history("battery");
QCOMPARE(history.size(), 5);
for (const auto &point : history) QVERIFY(std::isfinite(point.y()));
QVERIFY(changed.readingAt(before / 2).contains("Battery: 50 %"));
QCOMPARE(changed.grab().toImage(), reference.grab().toImage());
TrayStyle style; style.historyMs = 300000;
QCOMPARE(telemetryIcon(true, history, "%", style, now).pixmap(64, 64).toImage(),
telemetryIcon(true, reference.history("battery"), "%", style, now).pixmap(64, 64).toImage());
}
void intervalChangesPreserveGaps() {
Chart chart("%"); chart.addSeries("battery", "Battery"); chart.setSelected("battery", true);
for (qint64 time : {0, 15000, 90000}) chart.sample({{"battery", 50}}, 15000, time);
chart.sample({}, 15000, 105000);
chart.sample({{"battery", 50}}, 15000, 120000);
chart.setPowerState(true, {}, 125000); chart.setPowerState(false, false, 130000);
chart.sample({{"battery", 50}}, 15000, 135000);
chart.sample({{"battery", 50}}, 60000, 195000);
const auto history = chart.history("battery");
for (double gap : {90000., 105000., 135000.})
QVERIFY(std::any_of(history.cbegin(), history.cend(), [gap](const QPointF &p) {
return p.x() == gap && !std::isfinite(p.y());
}));
QVERIFY(chart.readingAt(45000).contains("Battery: —"));
QVERIFY(chart.readingAt(105000).contains("Battery: —"));
QVERIFY(chart.readingAt(127000).contains("Battery: —"));
QVERIFY(chart.readingAt(120000).contains("Battery: 50 %"));
}
void intervalHistoryRetainsBoundary() {
Chart chart("%"); chart.addSeries("battery", "Battery"); chart.setSelected("battery", true);
chart.sample({{"battery", 50}}, 60000, 0);
chart.sample({{"battery", 50}}, 60000, 60000);
chart.sample({{"battery", 50}}, 15000, 120000);
chart.sample({{"battery", 50}}, 15000, historyRetentionMs + 61000);
const auto history = chart.history("battery");
QCOMPARE(history.first().x(), 60000.);
QCOMPARE(history[1], QPointF(120000, 50)); // No artificial gap at the cadence transition.
QVERIFY(chart.readingAt(90000).contains("Battery: 50 %"));
chart.sample({{"battery", 50}}, 15000, historyRetentionMs + 121000);
QVERIFY(chart.readingAt(30000).contains("Battery: —")); // The old boundary has expired.
}
void clickableLegend() {
Chart chart("MHz"); chart.addSeries("cpu"); chart.setSelected("cpu", true);
Legend legend(&chart, {{"cpu", "CPU average", {}, "MHz"}});
@@ -307,7 +613,7 @@ private Q_SLOTS:
QCOMPARE(image.pixelColor(32, 3), QColor(Qt::red));
QCOMPARE(image.pixelColor(32, 32), QColor(Qt::black));
style.clamp = false; image = render(); QCOMPARE(image.pixelColor(32, 3), QColor(Qt::black));
style.border = false; style.transparent = true; image = render(); QCOMPARE(image.pixelColor(32, 1).alpha(), 0);
style.border = false; style.backgroundColor = Qt::transparent; image = render(); QCOMPARE(image.pixelColor(32, 1).alpha(), 0);
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;
@@ -340,7 +646,7 @@ private Q_SLOTS:
QCOMPARE(telemetryIcon(false, points, "%", style, 120000).pixmap(64, 64).toImage(), number);
QCOMPARE(image.pixelColor(60, 32), QColor(Qt::green));
}
void traySoftensPeaks() {
void trayShowsPeakColumns() {
TrayStyle style; style.border = false; style.backgroundColor = Qt::black;
style.lineColor = Qt::green; style.fill = false; style.historyMs = 300000;
QVector<QPointF> points;
@@ -351,8 +657,7 @@ private Q_SLOTS:
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;
band |= colour == QColor(Qt::green);
}
QVERIFY(band);
bool mean = false;
@@ -446,6 +751,55 @@ private Q_SLOTS:
chart.sample({{"cpu", 2500}}, 1000, now + 1000);
QVERIFY(chart.grab().toImage() != before);
}
void chartRefreshesAfterDisplayChanges() {
Chart chart("MHz"); chart.resize(650, 240);
chart.addSeries("cpu"); chart.setSelected("cpu", true);
chart.sample({{"cpu", 1000}}, 1000, 100000);
chart.sample({{"cpu", 2000}}, 1000, 101000);
chart.show(); QVERIFY(QTest::qWaitForWindowExposed(&chart));
QTest::mouseMove(&chart, QPoint(1, 1));
const auto original = chart.grab().toImage();
QCOMPARE(chart.grab().toImage(), original);
chart.setSelected("cpu", false);
QVERIFY(chart.grab().toImage() != original);
chart.setSelected("cpu", true);
QCOMPARE(chart.grab().toImage(), original);
chart.setHistoryWindow(300000, false);
const auto unstretched = chart.grab().toImage();
QVERIFY(unstretched != original);
auto palette = chart.palette(); palette.setColor(QPalette::Text, Qt::red);
chart.setPalette(palette);
const auto recoloured = chart.grab().toImage();
QVERIFY(recoloured != unstretched);
auto font = chart.font(); font.setPointSizeF(font.pointSizeF() + 2); chart.setFont(font);
QVERIFY(chart.grab().toImage() != recoloured);
chart.resize(800, 240);
QCOMPARE(chart.grab().size(), QSize(800, 240) * chart.devicePixelRatioF());
}
void stationaryHoverFollowsSamples() {
Chart chart("W"); chart.resize(850, 240);
chart.addSeries("power"); chart.setSelected("power", true);
chart.setHistoryWindow(300000, false);
const qint64 now = QDateTime::currentMSecsSinceEpoch();
chart.sample({{"power", 1}}, 1000, now);
chart.show(); QVERIFY(QTest::qWaitForWindowExposed(&chart));
// Wayland does not support QTest's global cursor warp. Send a local move.
const QPoint position(400, 100);
QMouseEvent move(QEvent::MouseMove, position, chart.mapToGlobal(position),
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(&chart, &move);
auto *popup = chart.findChild<QLabel *>("chartReadingPopup");
QVERIFY(popup); QVERIFY(popup->isVisible());
const auto time = chart.hoverTime(); QVERIFY(time);
const QString text = popup->text();
chart.sample({{"power", 2}}, 1000, now + 1000);
QCOMPARE(chart.hoverTime(), std::optional<qint64>(*time + 1000));
QVERIFY(popup->text() != text);
QCOMPARE(popup->text(), chart.readingAt(*chart.hoverTime()));
QTest::qWait(11000); // Past the usual tooltip inactivity timeout, without moving.
QVERIFY(popup->isVisible());
chart.hide(); QVERIFY(!popup->isVisible()); QVERIFY(!chart.hoverTime());
}
void sliderAndNumberStaySynchronized() {
ValueControl control;
control.setRange(400, 4800); control.setSuffix(" MHz"); control.setSingleStep(100);
@@ -469,9 +823,33 @@ private Q_SLOTS:
slider->setValue(0);
QCOMPARE(number->text(), QString("Firmware default"));
}
void slidersJumpToClick() {
JumpSlider slider(Qt::Horizontal); slider.setRange(0, 100); slider.setPageStep(10);
slider.resize(400, 40); slider.show();
const QPoint target(300, 20);
for (bool inverted : {false, true}) {
slider.setInvertedAppearance(inverted); slider.setValue(50);
QTest::mousePress(&slider, Qt::LeftButton, Qt::NoModifier, target);
QVERIFY(slider.isSliderDown());
QVERIFY(inverted ? slider.value() < 30 : slider.value() > 70);
const int pressed = slider.value();
QTest::qWait(550); QCOMPARE(slider.value(), pressed); // No page-step auto-repeat.
QTest::mouseMove(&slider, QPoint(200, 20));
QVERIFY(std::abs(slider.value() - 50) <= 1);
QTest::mouseRelease(&slider, Qt::LeftButton, Qt::NoModifier, QPoint(200, 20));
QVERIFY(!slider.isSliderDown());
}
slider.setInvertedAppearance(false); slider.setLayoutDirection(Qt::RightToLeft); slider.setValue(50);
QTest::mouseClick(&slider, Qt::LeftButton, Qt::NoModifier, target); QVERIFY(slider.value() < 30);
slider.setLayoutDirection(Qt::LeftToRight); slider.setValue(50);
QTest::keyClick(&slider, Qt::Key_Right); QCOMPARE(slider.value(), 51);
slider.setEnabled(false); QTest::mouseClick(&slider, Qt::LeftButton, Qt::NoModifier, target);
QCOMPARE(slider.value(), 51);
}
void pagesAndReadOnlyStartup() {
QTemporaryDir config;
qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path());
QSettings settings("fedora-tools", "framework-laptop-tools");
settings.setValue("sampling/fast", 0);
settings.setValue("sampling/battery", -5);
@@ -484,7 +862,31 @@ private Q_SLOTS:
QCOMPARE(tabs->tabText(3), QString("Battery"));
QCOMPARE(tabs->tabText(4), QString("CPU"));
QCOMPARE(tabs->tabText(5), QString("Tray icon"));
QCOMPARE(window.findChildren<Chart *>().size(), 4);
QCOMPARE(window.findChildren<Chart *>().size(), 5);
auto *powerChart = window.findChild<Chart *>("powerChart"); QVERIFY(powerChart);
for (QWidget *ancestor = powerChart; ancestor; ancestor = ancestor->parentWidget())
QVERIFY(ancestor->toolTip().isEmpty()); // No inherited help tooltip obscures graph readings.
const qreal titleSize = window.findChild<QLabel *>("chartTitle")->font().pointSizeF();
for (auto *group : window.findChildren<QGroupBox *>("settingsSection")) {
group->ensurePolished(); QCOMPARE(group->font().pointSizeF(), titleSize);
}
for (const auto *name : {"batteryCpuProfile", "acCpuProfile"}) {
auto *group = window.findChild<QGroupBox *>(name); QVERIFY(group);
group->ensurePolished(); QCOMPARE(group->font().pointSizeF(), titleSize);
for (auto *combo : group->findChildren<QComboBox *>())
QCOMPARE(combo->font().pointSizeF(), titleSize - 2);
}
QCOMPARE(window.findChild<QLabel *>("trayHoverTitle")->font().pointSizeF(), titleSize - 2);
QCOMPARE(window.findChild<QLabel *>("trayIconTitle")->font().pointSizeF(), titleSize);
QCOMPARE(window.findChild<QCheckBox *>("hover/cpu")->font().pointSizeF(), titleSize - 2);
for (auto *slider : window.findChildren<QSlider *>()) {
QVERIFY(slider->style()->styleHint(QStyle::SH_Slider_AbsoluteSetButtons, nullptr, slider) & Qt::LeftButton);
QVERIFY(!(slider->style()->styleHint(QStyle::SH_Slider_PageSetButtons, nullptr, slider) & Qt::LeftButton));
}
for (auto *title : window.findChildren<QLabel *>("chartTitle")) {
if (title->text() == "Fan speed" || title->text() == "Battery") continue;
QVERIFY(std::abs(title->geometry().center().x() - title->parentWidget()->rect().center().x()) <= 2);
}
for (auto *toggle : window.findChildren<QToolButton *>("extraTemperatureSensors")) QVERIFY(!toggle->isChecked());
const QString monitor = qEnvironmentVariable("FRAMEWORK_TOOLS_MONITOR_SCREENSHOT");
if (!monitor.isEmpty()) QVERIFY(window.grab().save(monitor));
@@ -568,6 +970,7 @@ private Q_SLOTS:
}
void sharedSaveAndUndo() {
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path());
QSettings settings("fedora-tools", "framework-laptop-tools");
Window window; window.show();
auto *bar = window.findChild<QWidget *>("pendingChanges"); QVERIFY(bar);
@@ -606,10 +1009,10 @@ private Q_SLOTS:
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);
QCOMPARE(settings.value("tray/icons/1/mode").toString(), QString("number")); QVERIFY(QFile::exists(path));
QCOMPARE(settings.value("tray/icons/1/historyMs").toInt(), 60000);
QVERIFY(!settings.value("tray/icons/1/hover/cpu").toBool());
QCOMPARE(settings.value("tray/icons/1/hover/topApps").toInt(), 3);
QVERIFY(!trayIcon->toolTip().startsWith("CPU "));
hoverCpu->setChecked(true);
hoverApps->setValue(1);
@@ -621,7 +1024,7 @@ private Q_SLOTS:
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 auto charts = window.findChildren<Chart *>(); QCOMPARE(charts.size(), 5);
const qint64 time = QDateTime::currentMSecsSinceEpoch() - 1000;
charts.first()->hovered(time);
for (auto *chart : charts) QCOMPARE(chart->hoverTime(), std::optional<qint64>(time));
@@ -630,6 +1033,7 @@ private Q_SLOTS:
}
void lowFanConfirmationCanBeCancelled() {
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path());
Window window; window.show();
auto *save = window.findChild<QPushButton *>("saveAll");
QTRY_VERIFY(save->isEnabled());
@@ -57,6 +57,8 @@ patched_checksum=$(sha256sum "$target")
run_tool apply >/dev/null
[[ $(sha256sum "$target") == "$patched_checksum" ]]
run_tool revert >/dev/null
cmp "$fixture" "$target"
run_tool revert >/dev/null
cmp "$fixture" "$target"
@@ -67,5 +69,20 @@ if run_tool apply >/dev/null 2>&1; then
exit 1
fi
[[ $(sha256sum "$target") == "$unsupported_checksum" ]]
if run_tool revert >/dev/null 2>&1; then
printf 'Expected an unsupported modified fixture to be rejected on revert.\n' >&2
exit 1
fi
[[ $(sha256sum "$target") == "$unsupported_checksum" ]]
status=0
run_tool status >/dev/null 2>&1 || status=$?
[[ $status -eq 2 ]]
rm -- "$target"
if run_tool apply >/dev/null 2>&1; then
printf 'Expected a missing lock screen to be rejected.\n' >&2
exit 1
fi
[[ ! -e $target ]]
printf 'plasma-always-show-unlock tests passed\n'
@@ -96,6 +96,7 @@ installed_base() {
}
show_status() {
local enabled=1
if ! rpm -q kscreenlocker >/dev/null 2>&1; then
printf 'KScreenLocker is not installed.\n'
return 1
@@ -103,18 +104,16 @@ show_status() {
if is_workaround_package -q kscreenlocker; then
printf 'Fingerprint workaround: enabled\n'
printf 'Installed package: kscreenlocker-%s.%s\n' \
"$(installed_value '%{EVR}')" "$(installed_value '%{ARCH}')"
printf 'Based on Fedora package: kscreenlocker-%s\n' "$(installed_base)"
elif is_legacy_workaround; then
printf 'Fingerprint workaround: enabled (legacy unmanaged build)\n'
printf 'Installed package: kscreenlocker-%s.%s\n' \
"$(installed_value '%{EVR}')" "$(installed_value '%{ARCH}')"
printf 'Based on Fedora package: kscreenlocker-%s\n' "$(installed_base)"
else
enabled=0
printf 'Fingerprint workaround: disabled\n'
printf 'Installed package: kscreenlocker-%s.%s\n' \
"$(installed_value '%{EVR}')" "$(installed_value '%{ARCH}')"
fi
printf 'Installed package: kscreenlocker-%s\n' \
"$(installed_value '%{EVR}.%{ARCH}')"
if (( enabled )); then
printf 'Based on Fedora package: kscreenlocker-%s\n' "$(installed_base)"
fi
}
@@ -1,6 +1,6 @@
Name: plasma-fingerprint-workaround
Version: 0.1.0
Release: 4%{?dist}
Release: 5%{?dist}
Summary: Opt-in patched KScreenLocker for fingerprint recovery after suspend
License: MIT
@@ -51,6 +51,9 @@ install -D -m 0644 %{SOURCE3} \
%{_datadir}/plasma-fingerprint-workaround/payload.conf
%changelog
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-5
- Simplify status queries and extend invalid-command tests
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-4
- Update the opt-in payload to Fedora KScreenLocker 6.7.5
@@ -8,10 +8,19 @@ payload_config=$2
bash -n "$controller"
"$controller" --help | grep -q '^ .* enable \[--rpm PATH\] \[--force\] \[--yes\]$'
if "$controller" unknown-command >/dev/null 2>&1; then
printf 'Unknown command unexpectedly succeeded.\n' >&2
exit 1
fi
reject() {
if "$controller" "$@" >/dev/null 2>&1; then
printf 'Invalid invocation unexpectedly succeeded: %s\n' "$*" >&2
exit 1
fi
}
reject
reject unknown-command
reject status --yes
reject enable --rpm
reject enable --unknown
reject disable --force
payload_url=
payload_sha256=
+4 -3
View File
@@ -9,7 +9,7 @@ include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
include(CTest)
find_package(Qt6 6.8 REQUIRED COMPONENTS Quick Widgets DBus Test QuickTest)
find_package(Qt6 6.8 REQUIRED COMPONENTS Quick Widgets DBus)
find_package(KF6 REQUIRED COMPONENTS Config I18n Service KIO)
add_library(panelactionsplugin MODULE src/plugin.cpp src/panelcontroller.cpp src/settings.cpp)
target_link_libraries(panelactionsplugin PRIVATE Qt6::Quick Qt6::DBus KF6::ConfigCore KF6::Service KF6::KIOGui)
@@ -21,8 +21,8 @@ target_link_libraries(plasma-panel-actions PRIVATE Qt6::Quick Qt6::Widgets Qt6::
# RPM normalizes file timestamps. A content-specific URL prevents Qt from
# reusing an older settings page's disk cache after a same-day upgrade.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS src/SettingsWindow.qml src/ConfigGeneral.qml)
file(SHA256 src/SettingsWindow.qml window_hash)
file(SHA256 src/ConfigGeneral.qml form_hash)
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/src/SettingsWindow.qml" window_hash)
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/src/ConfigGeneral.qml" form_hash)
string(SHA256 ui_hash "${window_hash}${form_hash}")
set(settings_ui_dir "plasma-panel-actions/${ui_hash}")
target_compile_definitions(plasma-panel-actions PRIVATE SETTINGS_UI_DIR="${settings_ui_dir}")
@@ -36,6 +36,7 @@ install(FILES data/qmldir DESTINATION ${KDE_INSTALL_QMLDIR}/se/ajpanton/panelact
install(DIRECTORY package/ DESTINATION ${KDE_INSTALL_DATADIR}/plasma/plasmoids/se.ajpanton.panelactions)
install(DIRECTORY controller/ DESTINATION ${KDE_INSTALL_DATADIR}/plasma/plasmoids/se.ajpanton.panelactions.controller)
if(BUILD_TESTING)
find_package(Qt6 6.8 REQUIRED COMPONENTS Test QuickTest)
add_executable(test-panelsettings tests/test-settings.cpp src/settings.cpp)
target_link_libraries(test-panelsettings PRIVATE Qt6::Test KF6::ConfigCore KF6::Service KF6::KIOGui)
target_include_directories(test-panelsettings PRIVATE src)
@@ -1,6 +1,6 @@
Name: plasma-panel-actions
Version: 0.1.0
Release: 10%{?dist}
Release: 11%{?dist}
Summary: Configurable panel scrolling and empty-space click actions
License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools
@@ -47,6 +47,10 @@ install -Dpm 0644 %{SOURCE1} %{buildroot}%{_licensedir}/%{name}/LICENSE
%{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-panel-actions-autostart.desktop
%{_datadir}/fedora-tools/settings/plasma-panel-actions.json
%changelog
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-11
- Reset partial scrolling when interactions are disabled
- Simplify configuration loading and fix incremental build dependencies
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-10
- Use Plasma's content boundaries for both clicks and scrolling at panel edges
* Wed Sep 09 2026 fedora-tools contributors - 0.1.0-9
+2 -1
View File
@@ -25,7 +25,8 @@ Kirigami.ApplicationWindow {
applications: panelSettings.applications
Layout.fillWidth: true
function load() {
for (const key in panelSettings.values) form["cfg_" + key] = panelSettings.values[key];
const values = panelSettings.values;
for (const key in values) form["cfg_" + key] = values[key];
}
Component.onCompleted: load()
}
+11 -13
View File
@@ -20,11 +20,7 @@ PanelController::PanelController(QQuickItem *parent) : QQuickItem(parent)
}
m_pendingButton = Qt::NoButton;
});
connect(this, &PanelController::settingsChanged, this, [this] {
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
m_pressedButton = Qt::NoButton;
});
connect(this, &PanelController::settingsChanged, this, &PanelController::resetInteraction);
connect(this, &QQuickItem::windowChanged, this, [this](QQuickWindow *window) {
if (m_panel) {
m_panel->removeEventFilter(this);
@@ -33,10 +29,7 @@ PanelController::PanelController(QQuickItem *parent) : QQuickItem(parent)
if (m_panel) {
m_panel->installEventFilter(this);
}
m_remainder = 0;
m_pressedButton = Qt::NoButton;
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
resetInteraction();
});
}
@@ -50,6 +43,14 @@ PanelController::~PanelController()
}
}
void PanelController::resetInteraction()
{
m_remainder = 0;
m_pressedButton = Qt::NoButton;
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
}
QString PanelController::areaAt(const QPointF &position)
{
// Dragging outside the panel must not project back onto one of its widgets.
@@ -68,10 +69,7 @@ QString PanelController::areaAt(const QPointF &position)
bool PanelController::eventFilter(QObject *object, QEvent *event)
{
if (object != m_panel || !m_active) {
m_remainder = 0;
m_pressedButton = Qt::NoButton;
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
resetInteraction();
return false;
}
if (event->type() == QEvent::Wheel) {
@@ -27,6 +27,7 @@ Q_SIGNALS:
protected:
bool eventFilter(QObject *object, QEvent *event) override;
private:
void resetInteraction();
QString areaAt(const QPointF &position);
QPointer<QQuickWindow> m_panel;
bool m_active = false;
@@ -135,6 +135,21 @@ private Q_SLOTS:
QCOMPARE(window.delivered, 4);
QCOMPARE(actions.count(), 1);
}
void disablingDiscardsPartialScroll()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
QSignalSpy actions(&controller, &PanelController::actionRequested);
wheel(window, 60);
controller.setProperty("active", false);
controller.setProperty("active", true);
wheel(window, 60);
QCOMPARE(actions.count(), 0);
wheel(window, 60);
QCOMPARE(actions.count(), 1);
}
void draggingCancelsClickEvenWhenReturning()
{
QJSEngine engine;
@@ -23,6 +23,7 @@ find_package(XKB REQUIRED)
# KWin caches both QML components and directory listings for its whole session.
# Changed helper code needs a new directory, not just a new filename.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS TaskbarGeometry.qml)
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/TaskbarGeometry.qml" geometry_hash)
set(geometry_dir "plasma-task-group-shortcuts/${geometry_hash}")
configure_file(TaskbarGeometry.qml "qml/${geometry_hash}/TaskbarGeometry.qml" COPYONLY)
@@ -1,6 +1,6 @@
Name: plasma-task-group-shortcuts
Version: 0.1.0
Release: 20%{?dist}
Release: 21%{?dist}
Summary: Windows-like application-group shortcuts for the Plasma Task Manager
License: MIT
@@ -62,6 +62,10 @@ install -Dpm 0644 %{SOURCE2} \
%{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop
%changelog
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-21
- Validate group indices and reduce repeated launcher lookups
- Refresh geometry resource hashes during incremental builds
* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-20
- Load updated geometry helpers without reusing KWin's cached QML
@@ -43,15 +43,15 @@ QVector<int> logicalTaskRows(const QAbstractItemModel &model, bool separateLaunc
continue;
}
const QString launcherAppId = launcher.data(AbstractTasksModel::AppId).toString();
const QUrl launcherUrl = launcher.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();
for (int windowRow = 0; windowRow < model.rowCount(); ++windowRow) {
const QModelIndex window = model.index(windowRow, 0);
if (!window.data(AbstractTasksModel::IsWindow).toBool()) {
continue;
}
const QString launcherAppId = launcher.data(AbstractTasksModel::AppId).toString();
const QString windowAppId = window.data(AbstractTasksModel::AppId).toString();
const QUrl launcherUrl = launcher.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();
const QUrl windowUrl = window.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();
if ((!launcherAppId.isEmpty() && launcherAppId == windowAppId)
|| (launcherUrl.isValid() && windowUrl.isValid()
@@ -76,6 +76,9 @@ QVector<int> logicalTaskRows(const QAbstractItemModel &model, bool separateLaunc
int initialCyclePosition(const QAbstractItemModel &model, int row, bool startWithFirst, bool backwards)
{
const QModelIndex group = model.index(row, 0);
if (!group.isValid()) {
return 0;
}
const int childCount = model.rowCount(group);
if (childCount == 0) {
return 0;
@@ -31,6 +31,8 @@ void TaskSelectionTest::returnsTopLevelTask()
QCOMPARE(taskToActivate(model, 0, 0), model.index(0, 0));
QVERIFY(!taskToActivate(model, 1, 0).isValid());
QCOMPARE(initialCyclePosition(model, 1, false, true), 0);
QCOMPARE(initialCyclePosition(model, -1, false, true), 0);
}
void TaskSelectionTest::selectsChildByCyclePosition()
+2 -6
View File
@@ -7,15 +7,11 @@ set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
tool_dir="$repo_root/fedora-tools-settings"
topdir="$repo_root/rpmbuild"
source_dir="$topdir/SOURCES/fedora-tools-settings-0.1.0"
mkdir -p "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
rm -rf -- "$source_dir"
mkdir -p "$source_dir"
cp -a "$tool_dir/CMakeLists.txt" "$tool_dir/src" "$tool_dir/tests" "$source_dir/"
tar -C "$topdir/SOURCES" -czf \
tar -C "$tool_dir" --transform='s,^,fedora-tools-settings-0.1.0/,' -czf \
"$topdir/SOURCES/fedora-tools-settings-0.1.0.tar.gz" \
fedora-tools-settings-0.1.0
CMakeLists.txt src tests
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
install -m 0644 "$tool_dir/README.md" "$topdir/SOURCES/README.md"
+6 -31
View File
@@ -7,39 +7,14 @@ set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
tool_dir="$repo_root/plasma-task-group-shortcuts"
topdir="$repo_root/rpmbuild"
source_dir="$topdir/SOURCES/plasma-task-group-shortcuts-0.1.0"
mkdir -p "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
mkdir -p "$source_dir/tests"
install -m 0644 "$tool_dir/CMakeLists.txt" "$source_dir/CMakeLists.txt"
install -m 0644 "$tool_dir/keyboardlayout.cpp" "$source_dir/keyboardlayout.cpp"
install -m 0644 "$tool_dir/keyboardlayout.h" "$source_dir/keyboardlayout.h"
install -m 0644 "$tool_dir/main.cpp" "$source_dir/main.cpp"
install -m 0644 "$tool_dir/pendingselection.cpp" "$source_dir/pendingselection.cpp"
install -m 0644 "$tool_dir/pendingselection.h" "$source_dir/pendingselection.h"
install -m 0644 "$tool_dir/windowpreview.cpp" "$source_dir/windowpreview.cpp"
install -m 0644 "$tool_dir/windowpreview.h" "$source_dir/windowpreview.h"
install -m 0644 "$tool_dir/Preview.qml" "$source_dir/Preview.qml"
install -m 0644 "$tool_dir/TaskbarGeometry.qml" "$source_dir/TaskbarGeometry.qml"
install -m 0644 "$tool_dir/taskbargeometry.cpp" "$source_dir/taskbargeometry.cpp"
install -m 0644 "$tool_dir/taskbargeometry.h" "$source_dir/taskbargeometry.h"
install -m 0644 "$tool_dir/previewplacement.cpp" "$source_dir/previewplacement.cpp"
install -m 0644 "$tool_dir/previewplacement.h" "$source_dir/previewplacement.h"
install -m 0644 "$tool_dir/tests/test-pendingselection.cpp" "$source_dir/tests/test-pendingselection.cpp"
install -m 0644 "$tool_dir/tests/test-preview.cpp" "$source_dir/tests/test-preview.cpp"
install -m 0644 "$tool_dir/taskselection.cpp" "$source_dir/taskselection.cpp"
install -m 0644 "$tool_dir/taskselection.h" "$source_dir/taskselection.h"
install -m 0644 "$tool_dir/tests/test-taskselection.cpp" \
"$source_dir/tests/test-taskselection.cpp"
install -m 0644 "$tool_dir/tests/test-keyboardlayout.cpp" \
"$source_dir/tests/test-keyboardlayout.cpp"
install -m 0644 "$tool_dir/se.ajpanton.plasma-task-group-shortcuts.desktop" \
"$source_dir/se.ajpanton.plasma-task-group-shortcuts.desktop"
install -m 0644 "$tool_dir/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop" \
"$source_dir/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop"
tar -C "$topdir/SOURCES" -czf \
"$topdir/SOURCES/plasma-task-group-shortcuts-0.1.0.tar.gz" \
plasma-task-group-shortcuts-0.1.0
(
cd -- "$tool_dir"
tar --transform='s,^,plasma-task-group-shortcuts-0.1.0/,' \
-czf "$topdir/SOURCES/plasma-task-group-shortcuts-0.1.0.tar.gz" \
CMakeLists.txt *.cpp *.h *.qml *.desktop tests
)
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
install -m 0644 "$repo_root/README.md" "$topdir/SOURCES/README.md"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# Requires the Fedora build dependencies for all seven tools; never installs them.
set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
build_dir=${FEDORA_TOOLS_TEST_BUILD_DIR:-"$repo_root/local/test-build"}
cd -- "$repo_root"
for script in scripts/*; do
if [[ -f $script ]]; then bash -n "$script"; fi
done
bash scripts/tests/test-source-archives
plugin=touchpad-hold-tap/90-touchpad-hold-tap.lua
lua touchpad-hold-tap/tests/test-touchpad-hold-tap.lua "$plugin"
lua touchpad-hold-tap/tests/test-touchpad-hold-tap.lua "$plugin" KEY_F13
lua touchpad-hold-tap/tests/test-touchpad-hold-tap.lua "$plugin" BTN_MIDDLE allow-moving-anchor
bash touchpad-hold-tap/tests/test-touchpad-hold-tap-config "$plugin" touchpad-hold-tap/touchpad-hold-tap-config
bash plasma-always-show-unlock/tests/test-plasma-always-show-unlock \
plasma-always-show-unlock/plasma-always-show-unlock \
plasma-always-show-unlock/LockScreenUi.patch plasma-always-show-unlock/tests/LockScreenUi.qml
bash plasma-fingerprint-workaround/tests/test-controller \
plasma-fingerprint-workaround/plasma-fingerprint-workaround plasma-fingerprint-workaround/payload.conf
for tool in plasma-task-group-shortcuts plasma-panel-actions fedora-tools-settings framework-laptop-tools; do
cmake -S "$tool" -B "$build_dir/$tool" -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build "$build_dir/$tool" --parallel "${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \
ctest --test-dir "$build_dir/$tool" --output-on-failure
done
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT
mkdir -p "$work/repo/scripts" "$work/bin"
cp "$repo_root/LICENSE" "$repo_root/README.md" "$work/repo/"
# Exercise source preparation, without building or installing RPMs.
printf '#!/bin/sh\nexit 0\n' > "$work/bin/rpmbuild"
chmod +x "$work/bin/rpmbuild"
export PATH="$work/bin:$PATH"
for tool in plasma-task-group-shortcuts fedora-tools-settings; do
cp -a "$repo_root/$tool" "$work/repo/"
cp "$repo_root/scripts/build-$tool-rpm" "$work/repo/scripts/"
source_dir="$work/repo/rpmbuild/SOURCES/$tool-0.1.0"
mkdir -p "$source_dir"
touch "$source_dir/obsolete.cpp"
bash "$work/repo/scripts/build-$tool-rpm"
archive="$work/repo/rpmbuild/SOURCES/$tool-0.1.0.tar.gz"
files=$(tar -tzf "$archive")
if [[ $files == *obsolete.cpp* ]]; then
printf 'Stale source included in %s\n' "$archive" >&2
exit 1
fi
mkdir -p "$work/extracted/$tool"
tar -xzf "$archive" -C "$work/extracted/$tool"
extracted="$work/extracted/$tool/$tool-0.1.0"
cmp "$work/repo/$tool/CMakeLists.txt" "$extracted/CMakeLists.txt"
diff -r "$work/repo/$tool/tests" "$extracted/tests"
if [[ $tool == fedora-tools-settings ]]; then
diff -r "$work/repo/$tool/src" "$extracted/src"
else
for source in "$work/repo/$tool/"*.cpp "$work/repo/$tool/"*.h \
"$work/repo/$tool/"*.qml "$work/repo/$tool/"*.desktop; do
cmp "$source" "$extracted/${source##*/}"
done
fi
done
printf 'Source archive tests passed.\n'
+1 -1
View File
@@ -70,7 +70,7 @@ local function movement_exceeded_from(state, contact, origin_x, origin_y,
local dx = axis_distance(contact.x - origin_x, state.x_absinfo)
local dy = axis_distance(contact.y - origin_y, state.y_absinfo)
return math.sqrt(dx * dx + dy * dy) > limit_mm
return dx * dx + dy * dy > limit_mm * limit_mm
end
local function movement_exceeded(state, contact, limit_mm)
@@ -32,6 +32,31 @@ grep -q 'minimum_tap_ms = 15' "$override"
grep -q 'maximum_tap_ms = 225' "$override"
grep -q 'output_event = "KEY_F13"' "$override"
# Decimal input must neither be parsed as octal nor overflow Bash arithmetic.
TOUCHPAD_HOLD_TAP_TEST_ROOT=$test_root bash "$configurator" set --minimum-tap-ms 008
grep -qx 'MINIMUM_TAP_MS=8' "$config"
before_config=$(sha256sum "$config")
before_override=$(sha256sum "$override")
for invalid in 2001 18446744073709551616 -1 nope; do
if TOUCHPAD_HOLD_TAP_TEST_ROOT=$test_root bash "$configurator" set --minimum-tap-ms "$invalid"; then
exit 1
fi
[[ $(sha256sum "$config") == "$before_config" ]]
[[ $(sha256sum "$override") == "$before_override" ]]
done
# Invalid template leaves both installed files unchanged and no staging clutter.
printf '%s\n' 'invalid template' > "$test_root/usr/lib64/libinput/plugins/90-touchpad-hold-tap.lua"
if TOUCHPAD_HOLD_TAP_TEST_ROOT=$test_root bash "$configurator" set --minimum-pause-ms 150; then
exit 1
fi
[[ $(sha256sum "$config") == "$before_config" ]]
[[ $(sha256sum "$override") == "$before_override" ]]
[[ $(find "$test_root/etc" -type f | wc -l) == 2 ]]
printf 'MINIMUM_TAP_MS=12' > "$config"
TOUCHPAD_HOLD_TAP_TEST_ROOT=$test_root bash "$configurator" show | grep -qx 'MINIMUM_TAP_MS=12'
TOUCHPAD_HOLD_TAP_TEST_ROOT=$test_root bash "$configurator" reset
[[ ! -e $config ]]
[[ ! -e $override ]]
+29 -27
View File
@@ -17,6 +17,9 @@ minimum_pause_ms=100
minimum_tap_ms=10
maximum_tap_ms=150
output_event=BTN_MIDDLE
temporary_config=
temporary_override=
trap '[[ -z $temporary_config ]] || rm -f -- "$temporary_config"; [[ -z $temporary_override ]] || rm -f -- "$temporary_override"' EXIT
die()
{
@@ -33,13 +36,13 @@ require_root()
validate()
{
[[ $minimum_anchor_age_ms =~ ^[0-9]+$ ]] || die "minimum anchor age must be an integer"
[[ $minimum_pause_ms =~ ^[0-9]+$ ]] || die "minimum pause must be an integer"
[[ $minimum_tap_ms =~ ^[0-9]+$ ]] || die "minimum tap duration must be an integer"
[[ $maximum_tap_ms =~ ^[0-9]+$ ]] || die "maximum tap duration must be an integer"
(( minimum_anchor_age_ms <= 2000 )) || die "minimum anchor age must not exceed 2000 ms"
(( minimum_pause_ms <= 2000 )) || die "minimum pause must not exceed 2000 ms"
(( maximum_tap_ms >= 1 && minimum_tap_ms <= maximum_tap_ms && maximum_tap_ms <= 2000 )) || \
local name
for name in minimum_anchor_age_ms minimum_pause_ms minimum_tap_ms maximum_tap_ms; do
[[ ${!name} =~ ^0*([0-9]{1,4})$ ]] || die "${name//_/ } must be an integer between 0 and 2000"
printf -v "$name" '%d' "$((10#${BASH_REMATCH[1]}))"
(( ${!name} <= 2000 )) || die "${name//_/ } must not exceed 2000 ms"
done
(( maximum_tap_ms >= 1 && minimum_tap_ms <= maximum_tap_ms )) || \
die "tap durations must be between 0 and 2000 ms, with a positive maximum and minimum not exceeding maximum"
[[ $output_event =~ ^(BTN|KEY)_[A-Z0-9_]+$ ]] || \
die "output event must be an evdev BTN_* or KEY_* name"
@@ -51,7 +54,7 @@ read_config()
return 0
fi
while IFS='=' read -r key value; do
while IFS='=' read -r key value || [[ -n $key ]]; do
case $key in
MINIMUM_ANCHOR_AGE_MS) minimum_anchor_age_ms=$value ;;
MINIMUM_PAUSE_MS) minimum_pause_ms=$value ;;
@@ -65,27 +68,26 @@ read_config()
validate
}
print_config()
{
printf 'MINIMUM_ANCHOR_AGE_MS=%s\nMINIMUM_PAUSE_MS=%s\nMINIMUM_TAP_MS=%s\nMAXIMUM_TAP_MS=%s\nOUTPUT_EVENT=%s\n' \
"$minimum_anchor_age_ms" "$minimum_pause_ms" "$minimum_tap_ms" "$maximum_tap_ms" \
"$output_event"
}
write_config()
{
mkdir -p "$(dirname -- "$config_file")"
local temporary
temporary=$(mktemp "${config_file}.XXXXXX")
trap 'rm -f -- "$temporary"' RETURN
printf 'MINIMUM_ANCHOR_AGE_MS=%s\nMINIMUM_PAUSE_MS=%s\nMINIMUM_TAP_MS=%s\nMAXIMUM_TAP_MS=%s\nOUTPUT_EVENT=%s\n' \
"$minimum_anchor_age_ms" "$minimum_pause_ms" "$minimum_tap_ms" "$maximum_tap_ms" \
"$output_event" > "$temporary"
chmod 0644 "$temporary"
mv -f -- "$temporary" "$config_file"
trap - RETURN
temporary_config=$(mktemp "${config_file}.XXXXXX")
print_config > "$temporary_config"
chmod 0644 "$temporary_config"
}
write_override()
{
[[ -r $template_file ]] || die "installed plugin was not found"
mkdir -p "$(dirname -- "$override_file")"
local temporary
temporary=$(mktemp "${override_file}.XXXXXX")
trap 'rm -f -- "$temporary"' RETURN
temporary_override=$(mktemp "${override_file}.XXXXXX")
awk -v age="$minimum_anchor_age_ms" -v pause="$minimum_pause_ms" \
-v mintap="$minimum_tap_ms" \
-v maxtap="$maximum_tap_ms" \
@@ -103,10 +105,8 @@ write_override()
}
{ print }
END { if (!replaced) exit 1 }
' "$template_file" > "$temporary" || die "installed plugin has an unsupported format"
chmod 0644 "$temporary"
mv -f -- "$temporary" "$override_file"
trap - RETURN
' "$template_file" > "$temporary_override" || die "installed plugin has an unsupported format"
chmod 0644 "$temporary_override"
}
usage()
@@ -125,9 +125,7 @@ case $command in
show)
[[ $# -eq 1 ]] || die "show takes no arguments"
read_config
printf 'MINIMUM_ANCHOR_AGE_MS=%s\nMINIMUM_PAUSE_MS=%s\nMINIMUM_TAP_MS=%s\nMAXIMUM_TAP_MS=%s\nOUTPUT_EVENT=%s\n' \
"$minimum_anchor_age_ms" "$minimum_pause_ms" "$minimum_tap_ms" "$maximum_tap_ms" \
"$output_event"
print_config
;;
set)
shift
@@ -166,6 +164,9 @@ case $command in
validate
write_config
write_override
# Stage both files before replacing either: a bad template must not save settings.
mv -f -- "$temporary_config" "$config_file"
mv -f -- "$temporary_override" "$override_file"
;;
reset)
[[ $# -eq 1 ]] || die "reset takes no arguments"
@@ -178,6 +179,7 @@ case $command in
if [[ -e $config_file ]]; then
read_config
write_override
mv -f -- "$temporary_override" "$override_file"
else
rm -f -- "$override_file"
fi
+5 -1
View File
@@ -1,6 +1,6 @@
Name: touchpad-hold-tap
Version: 0.1.0
Release: 10%{?dist}
Release: 11%{?dist}
Summary: Hold-tap middle-click gesture for touchpads
License: MIT
@@ -57,6 +57,10 @@ if [ "$1" -eq 0 ]; then
fi
%changelog
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-11
- Validate timing integers and stage configuration before replacing files
- Reduce movement calculation overhead and expand regression tests
* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-10
- Reset stale touch contacts after an interrupted input stream