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(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE) 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) find_package(KF6 6.0 REQUIRED COMPONENTS Auth Config CoreAddons I18n KCMUtils)
add_subdirectory(src) add_subdirectory(src)
if(BUILD_TESTING) if(BUILD_TESTING)
find_package(Qt6 6.8 REQUIRED COMPONENTS Test)
add_subdirectory(tests) add_subdirectory(tests)
endif() endif()
@@ -1,6 +1,6 @@
Name: fedora-tools-settings Name: fedora-tools-settings
Version: 0.1.0 Version: 0.1.0
Release: 12%{?dist} Release: 13%{?dist}
Summary: Plasma System Settings module for Fedora Tools Summary: Plasma System Settings module for Fedora Tools
License: MIT License: MIT
@@ -74,6 +74,10 @@ done
%{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy %{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy
%changelog %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 * Tue Sep 08 2026 fedora-tools contributors - 0.1.0-12
- Hide unsupported Framework hardware tools and flag incompatible local installs - Hide unsupported Framework hardware tools and flag incompatible local installs
+9 -9
View File
@@ -11,7 +11,6 @@
#include <KSharedConfig> #include <KSharedConfig>
#include <QFile> #include <QFile>
#include <QRegularExpression>
#include <QTimer> #include <QTimer>
#include <algorithm> #include <algorithm>
@@ -212,7 +211,10 @@ void FedoraToolsKcm::saveShortcutSettings(bool startWithFirst,
group.writeEntry("StartWithFirstWindow", startWithFirst); group.writeEntry("StartWithFirstWindow", startWithFirst);
group.writeEntry("InitialShiftOpensNewInstance", initialShiftOpensNew); group.writeEntry("InitialShiftOpensNewInstance", initialShiftOpensNew);
group.writeEntry("ShiftCyclesBackward", shiftCyclesBackward); group.writeEntry("ShiftCyclesBackward", shiftCyclesBackward);
group.sync(); if (!group.sync()) {
setMessage(i18n("Could not save shortcut settings."), true);
return;
}
loadToolSettings(); loadToolSettings();
setMessage(i18n("Shortcut settings were saved.")); setMessage(i18n("Shortcut settings were saved."));
} }
@@ -222,7 +224,10 @@ void FedoraToolsKcm::resetShortcutSettings()
const auto config = KSharedConfig::openConfig(QStringLiteral("plasma-task-group-shortcutsrc")); const auto config = KSharedConfig::openConfig(QStringLiteral("plasma-task-group-shortcutsrc"));
KConfigGroup group(config, QStringLiteral("Settings")); KConfigGroup group(config, QStringLiteral("Settings"));
group.deleteGroup(); group.deleteGroup();
group.sync(); if (!group.sync()) {
setMessage(i18n("Could not reset shortcut settings."), true);
return;
}
loadToolSettings(); loadToolSettings();
setMessage(i18n("Shortcut settings were reset to their defaults.")); setMessage(i18n("Shortcut settings were reset to their defaults."));
} }
@@ -233,15 +238,10 @@ void FedoraToolsKcm::saveTouchpadSettings(int minimumAnchorAge,
int maximumTap, int maximumTap,
const QString &outputEvent) const QString &outputEvent)
{ {
static const QRegularExpression eventName(QStringLiteral("^(BTN|KEY)_[A-Z0-9_]+$"));
if (m_busy) { if (m_busy) {
return; return;
} }
if (minimumAnchorAge < 0 || minimumAnchorAge > 2000 if (!validTouchpadSettings(minimumAnchorAge, minimumPause, minimumTap, maximumTap, outputEvent)) {
|| minimumPause < 0 || minimumPause > 2000
|| minimumTap < 0 || minimumTap > maximumTap
|| maximumTap < 1 || maximumTap > 2000
|| !eventName.match(outputEvent).hasMatch()) {
setMessage(i18n("Enter valid timing values and an evdev BTN_* or KEY_* output event."), true); setMessage(i18n("Enter valid timing values and an evdev BTN_* or KEY_* output event."), true);
return; return;
} }
+7 -11
View File
@@ -8,7 +8,6 @@
#include <QFileInfo> #include <QFileInfo>
#include <QObject> #include <QObject>
#include <QProcess> #include <QProcess>
#include <QRegularExpression>
#include <sys/stat.h> #include <sys/stat.h>
@@ -192,17 +191,14 @@ public Q_SLOTS:
return KAuth::ActionReply::InvalidActionReply(); return KAuth::ActionReply::InvalidActionReply();
} }
const int minimumAnchorAge = arguments.value(QStringLiteral("minimumAnchorAge")).toInt(); bool ageValid, pauseValid, tapValid, maximumValid;
const int minimumPause = arguments.value(QStringLiteral("minimumPause")).toInt(); const int minimumAnchorAge = arguments.value(QStringLiteral("minimumAnchorAge")).toInt(&ageValid);
const int minimumTap = arguments.value(QStringLiteral("minimumTap")).toInt(); const int minimumPause = arguments.value(QStringLiteral("minimumPause")).toInt(&pauseValid);
const int maximumTap = arguments.value(QStringLiteral("maximumTap")).toInt(); 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(); const QString outputEvent = arguments.value(QStringLiteral("outputEvent")).toString();
static const QRegularExpression eventName(QStringLiteral("^(BTN|KEY)_[A-Z0-9_]+$")); if (!ageValid || !pauseValid || !tapValid || !maximumValid
if (minimumAnchorAge < 0 || minimumAnchorAge > 2000 || !validTouchpadSettings(minimumAnchorAge, minimumPause, minimumTap, maximumTap, outputEvent)) {
|| minimumPause < 0 || minimumPause > 2000
|| minimumTap < 0 || minimumTap > maximumTap
|| maximumTap < 1 || maximumTap > 2000
|| !eventName.match(outputEvent).hasMatch()) {
KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply(); KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply();
reply.addData(QStringLiteral("message"), QStringLiteral("The requested touchpad settings are invalid.")); reply.addData(QStringLiteral("message"), QStringLiteral("The requested touchpad settings are invalid."));
return reply; return reply;
+12 -1
View File
@@ -36,7 +36,7 @@ QList<PackageRecord> parsePackageRecords(const QByteArray &output)
bool isValidPackageName(const QString &name) 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(); 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 {}; && 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)."); 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); QList<PackageRecord> parsePackageRecords(const QByteArray &output);
bool isValidPackageName(const QString &name); 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")); 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); 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(); beginResetModel();
m_tools = std::move(merged); m_tools = tools.values(); // QMap already orders entries by package name.
endResetModel(); endResetModel();
} }
@@ -12,6 +12,17 @@ class PackageUtilsTest : public QObject
Q_OBJECT Q_OBJECT
private Q_SLOTS: 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() { void frameworkCompatibility() {
QTemporaryDir root; QTemporaryDir root;
QVERIFY(packageCompatibilityError(QStringLiteral("touchpad-hold-tap"), root.path()).isEmpty()); QVERIFY(packageCompatibilityError(QStringLiteral("touchpad-hold-tap"), root.path()).isEmpty());
@@ -43,6 +54,7 @@ private Q_SLOTS:
QVERIFY(!isValidPackageName(QStringLiteral("--installroot=/tmp"))); QVERIFY(!isValidPackageName(QStringLiteral("--installroot=/tmp")));
QVERIFY(!isValidPackageName(QStringLiteral("tool; reboot"))); QVERIFY(!isValidPackageName(QStringLiteral("tool; reboot")));
QVERIFY(!isValidPackageName(QStringLiteral("Tool"))); 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(KDEInstallDirs)
include(KDECMakeSettings) include(KDECMakeSettings)
include(CTest) 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(KF6Auth REQUIRED)
find_package(KF6Service REQUIRED) find_package(KF6Service REQUIRED)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
pkg_check_modules(Systemd REQUIRED IMPORTED_TARGET libsystemd) 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_include_directories(framework-hardware PUBLIC src)
target_link_libraries(framework-hardware PUBLIC Qt6::Core) 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) 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-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) install(FILES data/framework-laptop-tools-cpu.service DESTINATION /usr/lib/systemd/system)
if(BUILD_TESTING) if(BUILD_TESTING)
find_package(Qt6 6.8 REQUIRED COMPONENTS Test)
add_executable(test-hardware tests/test-hardware.cpp) add_executable(test-hardware tests/test-hardware.cpp)
target_link_libraries(test-hardware PRIVATE framework-hardware Qt6::Test) target_link_libraries(test-hardware PRIVATE framework-hardware Qt6::Test)
add_test(NAME hardware COMMAND test-hardware) 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. 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 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 Hover for timestamps and the nearest
available readings; no readings are invented across gaps or sleep. Battery charge 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, 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. and are remembered; the default is 24 hours with stretching enabled.
All detected sensors retain 24 hours of readings in memory, even when hidden, 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. plus one boundary sample for clipping. Quitting the app clears this history.
Monitor and tray lines show time-weighted averages per pixel, assuming straight Changing the sampling interval preserves older lines and hover readings at their
lines between readings. A faint band extends from the average to the maximum; original cadence; missing readings and sleep remain gaps.
brief peaks remain visible without dominating the solid line. Missing data and Monitor and tray graphs fill each pixel column from its time-weighted average
sleep remain gaps. Hover readings still report the original samples. 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. 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. 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. include CPU usage, CPU/GPU frequency, temperatures, charge level and battery rate.
Sensor graphs share Monitor's history, including gaps, regardless of which Sensor graphs share Monitor's history, including gaps, regardless of which
sensor is selected for the tray. CPU usage also retains 24 hours. Switching sensor is selected for the tray. CPU usage also retains 24 hours. Switching
readings or resuming from sleep does not clear history. Frequency numbers use readings or resuming from sleep does not clear history. Frequency numbers use
GHz. The tray has optional 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 area fill (including adjustable opacity). Frequency ceilings and temperature
ranges are saved per sensor; battery rate also has adjustable bounds (initially ranges are saved per sensor; battery rate also has adjustable bounds (initially
7575 W), while percentages use 0100. 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. 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 Colour buttons show the opaque RGB swatch and label opacity separately, so a
translucent fill is not mistaken for a darker RGB colour. 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 History and out-of-range controls are nested under the History graph display
choice. Hover information is configured separately: CPU usage; battery level 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 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 Missing sensors are unavailable in the settings page. Batteries reporting only
charge are converted to mWh using nominal voltage. charge are converted to mWh using nominal voltage.
Choose zero to three top CPU applications, displayed below CPU usage in descending 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 one. Readable CPU counters are sampled while enabled. Processes are grouped by
application where identifiable, otherwise by executable. KDE's catalogue supplies application where identifiable, otherwise by executable. KDE's catalogue supplies
friendly names; ambiguous matches use executable names. Percentages use total friendly names; ambiguous matches use executable names. Percentages use total
@@ -1,6 +1,6 @@
Name: framework-laptop-tools Name: framework-laptop-tools
Version: 0.1.0 Version: 0.1.0
Release: 15%{?dist} Release: 25%{?dist}
Summary: Hardware controls and monitoring for Framework Laptop 13 Pro Summary: Hardware controls and monitoring for Framework Laptop 13 Pro
License: MIT License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools 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-fan-resume.service
%{_unitdir}/framework-laptop-tools-cpu.service %{_unitdir}/framework-laptop-tools-cpu.service
%changelog %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 * Fri Sep 11 2026 fedora-tools contributors - 0.1.0-15
- Expand tray hover details and allow zero to three top CPU applications - Expand tray hover details and allow zero to three top CPU applications
+125 -43
View File
@@ -2,9 +2,10 @@
#include "chart.h" #include "chart.h"
#include "graphdata.h" #include "graphdata.h"
#include <QPainter> #include <QPainter>
#include <QPainterPath>
#include <QMouseEvent> #include <QMouseEvent>
#include <QToolTip> #include <QToolTip>
#include <QGuiApplication>
#include <QScreen>
#include <QVector3D> #include <QVector3D>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
@@ -56,17 +57,15 @@ AxisTicks AxisTicks::covering(double minimum, double maximum, int intervals)
} }
double timeTickStep(double spanMs, int intervals) double timeTickStep(double spanMs, int intervals)
{ {
const double target = spanMs / 1000 / std::max(1, intervals); const double unit = spanMs >= 3600000 ? 3600000 : spanMs >= 60000 ? 60000 : 1000;
for (double seconds : {1., 2., 5., 10., 15., 30., 60., 120., 300., 600., 900., 1800., 3600., 7200., 10800., 21600., 43200., 86400.}) return niceStep(spanMs / unit / std::max(1, intervals)) * unit;
if (seconds >= target) return seconds * 1000;
return niceStep(target / 86400) * 86400000;
} }
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) return "Now";
if (seconds % 3600 == 0) return QString::number(seconds / 3600) + "h"; if (spanMs >= 3600000) return QString::number(seconds / 3600, 'g', 3) + "h";
if (seconds % 60 == 0) return QString::number(seconds / 60) + "min"; if (spanMs >= 60000) return QString::number(seconds / 60, 'g', 3) + "min";
return QString::number(seconds) + "s"; return QString::number(seconds) + "s";
} }
Chart::Chart(const QString &unit, QWidget *parent) : QWidget(parent), m_unit(unit) 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); setMinimumHeight(175);
setMouseTracking(true); setMouseTracking(true);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); 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) void Chart::addSeries(const QString &id, const QString &name, const QString &unit)
{ {
m_series.insert(id, {QColor(), m_series.insert(id, {QColor(),
name.isEmpty() ? id : name, unit.isEmpty() ? m_unit : unit, {}}); name.isEmpty() ? id : name, unit.isEmpty() ? m_unit : unit, {}});
invalidatePlot();
} }
void Chart::setSelected(const QString &id, bool selected) 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_series[id].color = nextColour(used);
m_selected.insert(id); m_selected.insert(id);
} else m_selected.remove(id); } else m_selected.remove(id);
update(); invalidatePlot();
} }
QColor Chart::color(const QString &id) const 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; if (series == m_series.cend()) return points;
for (auto it = historyStart(series->points, since); it != series->points.cend(); ++it) { for (auto it = historyStart(series->points, since); it != series->points.cend(); ++it) {
const auto &point = *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.x(), NAN});
points.append(point); 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) 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; m_sampleTime = now;
double oldest = now; double oldest = now;
for (auto it = m_series.begin(); it != m_series.end(); ++it) { 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()); oldest = std::min(oldest, it->points.front().x());
} }
m_bands.removeIf([oldest](const Band &band) { return band.end && band.end < oldest; }); 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) 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. // Charger state cannot be observed during suspend; don't extend AC bands through it.
const std::optional<BandType> next = sleeping ? std::optional(BandType::Sleep) const std::optional<BandType> next = sleeping ? std::optional(BandType::Sleep)
: plugged.value_or(false) ? std::optional(BandType::Plugged) : std::nullopt; : 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 (!m_bands.isEmpty() && !m_bands.last().end) {
if (next == m_bands.last().type) return; if (next == m_bands.last().type) return;
m_bands.last().end = now; m_bands.last().end = now;
} }
if (next) m_bands.append({now, 0, *next}); if (next) m_bands.append({now, 0, *next});
update(); invalidatePlot();
} }
bool Chart::crossesSleep(double from, double to) const 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; if (band.type == BandType::Sleep && band.begin < to && (!band.end || band.end > from)) return true;
return false; 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) void Chart::setHistoryWindow(qint64 spanMs, bool stretch)
{ {
m_historyMs = std::clamp(spanMs, qint64(300000), historyRetentionMs); m_historyMs = std::clamp(spanMs, qint64(300000), historyRetentionMs);
m_stretch = stretch; m_stretch = stretch;
update(); invalidatePlot();
} }
QPair<double, double> Chart::timeRange() const QPair<double, double> Chart::timeRange() const
{ {
@@ -179,7 +203,10 @@ Chart::Frame Chart::frame() const
} }
const int timeTextWidth = fm.horizontalAdvance("100min"); const int timeTextWidth = fm.horizontalAdvance("100min");
const int margin = std::max(fm.horizontalAdvance("99999") + 10, unitWidth + timeTextWidth / 2 + 8); 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)); const int intervals = std::max(2, int(area.height()) / (fm.height() * 2));
return {area, first, now, AxisTicks::covering(low, high, intervals), return {area, first, now, AxisTicks::covering(low, high, intervals),
AxisTicks::covering(low2, high2, intervals), secondary}; 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; }); 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; const QPointF *closest = next != points.cend() ? &*next : nullptr;
if (next != points.cbegin() && (!closest || time - (next - 1)->x() < closest->x() - time)) closest = &*(next - 1); 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))); && 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 : "")); 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) void Chart::mouseMoveEvent(QMouseEvent *event)
{ {
const auto f = frame(); m_hoverPosition = event->position();
if (!f.area.contains(event->position())) { setHoverTime(-1); Q_EMIT hovered(-1); QToolTip::hideText(); return; } refreshHover();
const qint64 time = qRound64(f.first + (event->position().x() - f.area.left()) / f.area.width() * (f.last - f.first)); }
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); 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 *) void Chart::paintEvent(QPaintEvent *)
{ {
QPainter p(this); p.setRenderHint(QPainter::Antialiasing); // Hover moves only the overlay, not the potentially 24-hour history underneath.
const auto f = frame(); const auto area = f.area; 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; 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 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(); }; 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); const double pos = x(f.last - age);
p.setPen(grid); p.drawLine(QPointF(pos, area.top()), QPointF(pos, area.bottom())); 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.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; bool hasValues = false;
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) { for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
if (!selected(it.key())) continue; if (!selected(it.key())) continue;
const auto columns = timeAverages(historyStart(it->points, f.first), it->points.cend(), 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) { f.first, f.last, qRound(area.width() * devicePixelRatioF()), [this](const QPointF &a, const QPointF &b) {
return b.x() - a.x() <= m_interval * 3 && !crossesSleep(a.x(), b.x()); return connectedSamples(a.x(), b.x());
}); });
const auto &scale = it->unit == m_unit ? f.left : f.right; const auto &scale = it->unit == m_unit ? f.left : f.right;
QColor shade = it->color; shade.setAlphaF(.20); for (const auto &column : columns) {
for (const auto &column : columns) const double left = area.left() + column.index / devicePixelRatioF();
p.fillRect(QRectF(QPointF(x(column.begin), y(column.maximum, scale)), p.fillRect(columnBand(left, left + 1 / devicePixelRatioF(), y(column.maximum, scale),
QPointF(x(column.end), y(column.mean, scale))), shade); y(column.lower(), scale), 2, devicePixelRatioF()), it->color);
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;
} }
p.setPen(QPen(it->color, 2)); p.drawPath(line); hasValues |= !columns.isEmpty();
} }
p.restore(); 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"); } 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 <QMap>
#include <QSet> #include <QSet>
#include <optional> #include <optional>
#include <QLabel>
#include <QPixmap>
#include "history.h" #include "history.h"
struct AxisTicks { struct AxisTicks {
@@ -12,7 +14,7 @@ struct AxisTicks {
static AxisTicks covering(double minimum, double maximum, int intervals); static AxisTicks covering(double minimum, double maximum, int intervals);
}; };
double timeTickStep(double spanMs, int intervals); double timeTickStep(double spanMs, int intervals);
QString ageLabel(double milliseconds); QString ageLabel(double milliseconds, double spanMs);
class Chart : public QWidget { class Chart : public QWidget {
Q_OBJECT Q_OBJECT
@@ -38,20 +40,34 @@ protected:
void paintEvent(QPaintEvent *) override; void paintEvent(QPaintEvent *) override;
void mouseMoveEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override;
void leaveEvent(QEvent *event) override; void leaveEvent(QEvent *event) override;
void hideEvent(QHideEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void changeEvent(QEvent *event) override;
private: private:
struct Series { QColor color; QString name, unit; Samples points; }; struct Series { QColor color; QString name, unit; Samples points; };
enum class BandType { Sleep, Plugged }; enum class BandType { Sleep, Plugged };
struct Band { qint64 begin, end; BandType type; }; struct Band { qint64 begin, end; BandType type; };
struct Frame { QRectF area; double first, last; AxisTicks left, right; bool secondary; }; struct Frame { QRectF area; double first, last; AxisTicks left, right; bool secondary; };
Frame frame() const; 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; bool crossesSleep(double from, double to) const;
int intervalAt(double time) const;
bool connectedSamples(double from, double to) const;
QMap<QString, Series> m_series; QMap<QString, Series> m_series;
QSet<QString> m_selected; QSet<QString> m_selected;
QVector<Band> m_bands; QVector<Band> m_bands;
QString m_unit; QString m_unit;
int m_interval = 1000; QMap<qint64, int> m_intervals;
qint64 m_historyMs = historyRetentionMs; qint64 m_historyMs = historyRetentionMs;
bool m_stretch = true; bool m_stretch = true;
qint64 m_sampleTime = QDateTime::currentMSecsSinceEpoch(); qint64 m_sampleTime = QDateTime::currentMSecsSinceEpoch();
std::optional<qint64> m_hoverTime; 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 // SPDX-License-Identifier: MIT
#include "cpupage.h" #include "cpupage.h"
#include "widgets.h"
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -33,7 +34,7 @@ void CpuPage::Editor::setValue(const QVariantMap &value)
} }
CpuPage::Editor CpuPage::makeEditor(const QString &name) 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}; new ValueControl, new ValueControl, new QComboBox, new QComboBox, new QLabel};
editor.frequency->setObjectName("overrideFrequency"); editor.frequency->setObjectName("overrideFrequency");
editor.governor->setObjectName("cpuGovernor"); editor.preference->setObjectName("cpuPreference"); editor.governor->setObjectName("cpuGovernor"); editor.preference->setObjectName("cpuPreference");
+19 -17
View File
@@ -1,13 +1,16 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
#pragma once #pragma once
#include <QPointF> #include <QPointF>
#include <QRectF>
#include <QVector> #include <QVector>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
struct AverageColumn { struct AverageColumn {
double begin, end, mean, maximum; double begin, end, mean, maximum, minimum;
bool startsRun; bool compressed;
int index;
double lower() const { return compressed ? mean : minimum; }
}; };
// Integrate the piecewise-linear signal over each pixel's time interval. // 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. 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 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 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) { if (!startsRun && previousColumn == column) {
auto &bucket = result.last(); auto &bucket = result.last();
bucket.mean += (mean - bucket.mean) * ((stop - left) / (stop - bucket.begin)); bucket.mean += (mean - bucket.mean) * ((stop - left) / (stop - bucket.begin));
bucket.maximum = std::max(bucket.maximum, maximum); bucket.maximum = std::max(bucket.maximum, maximum);
bucket.minimum = std::min(bucket.minimum, minimum);
bucket.compressed |= compressed;
bucket.end = stop; 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; startsRun = false; previousColumn = column; left = stop;
} }
} }
return result; 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; const double padding = std::max(0., minimumHeight - (bottom - top)) / 2;
for (int i = 0; i < columns.size(); ++i) { top = std::floor((top - padding) * pixelRatio) / pixelRatio;
const auto &column = columns[i]; bottom = std::ceil((bottom + padding) * pixelRatio) / pixelRatio;
if (column.startsRun) { return QRectF(QPointF(left, top), QPointF(right, bottom));
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;
} }
+22 -19
View File
@@ -10,6 +10,19 @@
#include <QProcess> #include <QProcess>
#include <QSaveFile> #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 { class Helper : public QObject {
Q_OBJECT Q_OBJECT
KAuth::ActionReply reply(const QString &error, const QVariantMap &data = {}) { KAuth::ActionReply reply(const QString &error, const QVariantMap &data = {}) {
@@ -20,7 +33,9 @@ class Helper : public QObject {
QProcess process; QProcess process;
process.start("/usr/bin/systemctl", {verb, unit}); process.start("/usr/bin/systemctl", {verb, unit});
if (!process.waitForFinished(25000)) { process.kill(); process.waitForFinished(); return "Service did not respond."; } 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: public Q_SLOTS:
KAuth::ActionReply inspect(const QVariantMap &) { KAuth::ActionReply inspect(const QVariantMap &) {
@@ -50,7 +65,6 @@ public Q_SLOTS:
} }
} }
} }
data["fanPersistent"] = QFile::exists(savedFanConfigPath);
return reply({}, data); return reply({}, data);
} }
KAuth::ActionReply configure(const QVariantMap &args) { KAuth::ActionReply configure(const QVariantMap &args) {
@@ -80,12 +94,8 @@ public Q_SLOTS:
const auto config = normalizeCpuConfig(args.value("config").toMap()); const auto config = normalizeCpuConfig(args.value("config").toMap());
error = validateCpuConfig(config); error = validateCpuConfig(config);
if (!error.isEmpty()) return reply(error); if (!error.isEmpty()) return reply(error);
if (!QDir().mkpath("/etc/framework-laptop-tools")) return reply("Cannot create CPU configuration directory."); error = saveConfig(cpuConfigPath, config);
QSaveFile file(cpuConfigPath); if (!error.isEmpty()) return reply(error);
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());
const QString unit = "framework-laptop-tools-cpu.service"; const QString unit = "framework-laptop-tools-cpu.service";
if (hasCpuOverrides(config)) { if (hasCpuOverrides(config)) {
error = service("enable", unit); error = service("enable", unit);
@@ -101,23 +111,16 @@ public Q_SLOTS:
// Restore the old worker before replacing its configuration. // Restore the old worker before replacing its configuration.
error = service("stop"); error = service("stop");
if (!error.isEmpty()) return reply(error); 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"; 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"); error = service(persistent ? "enable" : "disable");
if (error.isEmpty()) error = service(persistent ? "enable" : "disable", "framework-laptop-tools-fan-resume.service"); if (error.isEmpty()) error = service(persistent ? "enable" : "disable", "framework-laptop-tools-fan-resume.service");
if (!error.isEmpty()) return reply(error); if (!error.isEmpty()) return reply(error);
if (args.value("mode") == "auto") error = restoreFan(); if (args.value("mode") == "auto") error = restoreFan();
else { else {
const QString path = persistent ? savedFanConfigPath : fanConfigPath; error = saveConfig(savedFanConfigPath, args);
if (!QDir().mkpath(QFileInfo(path).absolutePath())) error = "Cannot create fan settings directory."; if (error.isEmpty()) error = service("start");
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");
}
} }
} else if (operation == "powerAuto") error = setFirmwareValue(operation, 0); } else if (operation == "powerAuto") error = setFirmwareValue(operation, 0);
else if (value >= 0) error = setFirmwareValue(operation, value); 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; QVector<BusyApp> apps;
for (auto it = usage.cbegin(); it != usage.cend(); ++it) for (auto it = usage.cbegin(); it != usage.cend(); ++it)
apps.append({names[it.key()], std::min(100., 100. * *it / *totalDelta)}); 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; 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; return apps;
} }
+19 -37
View File
@@ -2,7 +2,6 @@
#include "tray.h" #include "tray.h"
#include "graphdata.h" #include "graphdata.h"
#include <QPainter> #include <QPainter>
#include <QPainterPath>
#include <cmath> #include <cmath>
#include <algorithm> #include <algorithm>
@@ -28,12 +27,12 @@ std::optional<double> CpuUsage::sample(const QString &procStat)
} }
QRectF trayPlotRect(bool border) 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); 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) 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); QPainter p(&pixmap); p.setRenderHint(QPainter::Antialiasing);
if (style.border) { p.setPen(QPen(style.borderColor, 2)); p.drawRect(QRectF(1, 1, 62, 62)); } if (style.border) { p.setPen(QPen(style.borderColor, 2)); p.drawRect(QRectF(1, 1, 62, 62)); }
const auto area = trayPlotRect(style.border); 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) { if (graph && !values.isEmpty() && style.maximum > style.minimum) {
const double last = now; const double last = now;
const double first = last - style.historyMs; const double first = last - style.historyMs;
const double span = std::max(1., last - first); p.save(); p.setClipRect(area.adjusted(0, -1, 0, 1));
p.save(); p.setClipRect(area.adjusted(-1, -1, 1, 1)); p.setRenderHint(QPainter::Antialiasing, false);
const auto position = [&](const QPointF &point) { const auto y = [&](double value) {
const double fraction = std::clamp((point.y() - style.minimum) / (style.maximum - style.minimum), 0., 1.); const double fraction = std::clamp((value - style.minimum) / (style.maximum - style.minimum), 0., 1.);
return QPointF(area.left() + (point.x() - first) / span * area.width(), area.bottom() - fraction * area.height()); 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 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 auto columns = timeAverages(values.cbegin(), values.cend(), first, last, std::ceil(area.width()),
[](const QPointF &, const QPointF &) { return true; }); [](const QPointF &, const QPointF &) { return true; });
for (const auto &column : columns) { for (const auto &column : columns) {
QColor shade = style.lineColor; shade.setAlphaF(shade.alphaF() * .20); const double left = area.left() + column.index, right = left + 1;
p.fillRect(QRectF(position({column.begin, column.maximum}), position({column.end, column.mean})), shade); const double top = y(column.maximum), bottom = y(column.lower());
} const bool visible = column.maximum >= style.minimum && column.lower() <= style.maximum;
const auto averages = averageLine(columns); if (style.fill && (visible || style.clamp))
QPainterPath line, overflow, fill; p.fillRect(QRectF(left, bottom, 1, area.bottom() - bottom), style.fillColor);
for (int i = 1; i < averages.size(); ++i) { if (visible)
const auto a = averages[i - 1], b = averages[i]; p.fillRect(columnBand(left, right, top, bottom), style.lineColor);
if (b.x() < first || a.x() > last) continue; if (style.clamp) {
if (!std::isfinite(a.y()) || !std::isfinite(b.y())) continue; if (column.maximum > style.maximum)
QVector<double> cuts{0, 1}; p.fillRect(columnBand(left, right, area.top(), area.top()), color(true));
if (a.y() != b.y()) for (double boundary : {style.minimum, style.maximum}) { if (column.lower() < style.minimum)
const double t = (boundary - a.y()) / (b.y() - a.y()); p.fillRect(columnBand(left, right, area.bottom(), area.bottom()), color(true));
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);
} }
} }
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(); p.restore();
} else { } else {
const QString value = !std::isfinite(latest) ? "" : unit == "MHz" const QString value = !std::isfinite(latest) ? "" : unit == "MHz"
+1 -1
View File
@@ -17,7 +17,7 @@ private:
std::optional<quint64> m_delta; std::optional<quint64> m_delta;
}; };
struct TrayStyle { 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; QColor borderColor, backgroundColor, fillColor, lineColor, outsideColor;
double minimum = 0, maximum = 100; double minimum = 0, maximum = 100;
qint64 historyMs = 60000; qint64 historyMs = 60000;
+189 -11
View File
@@ -2,6 +2,7 @@
#include "traypage.h" #include "traypage.h"
#include "colorbutton.h" #include "colorbutton.h"
#include "tooltip.h" #include "tooltip.h"
#include "widgets.h"
#include <QGroupBox> #include <QGroupBox>
#include <QSpinBox> #include <QSpinBox>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -9,11 +10,36 @@
#include <QColorDialog> #include <QColorDialog>
#include <QLabel> #include <QLabel>
#include <QSignalBlocker> #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) 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 *layout = new QVBoxLayout(this);
auto *form = new QFormLayout; form->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); layout->addLayout(form); auto *form = new QFormLayout; form->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); layout->addLayout(form);
m_mode = new QComboBox; m_mode->setObjectName("trayDisplayMode"); 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); m_border = check(colors, "Show border", "border", true);
auto *borderColor = colorRow(colors, "Border colour:", "borderColor", palette().color(QPalette::WindowText)); auto *borderColor = colorRow(colors, "Border colour:", "borderColor", palette().color(QPalette::WindowText));
borderColor->setEnabled(m_border->isChecked()); connect(m_border, &QCheckBox::toggled, borderColor, &QWidget::setEnabled); colors->setRowVisible(borderColor, m_border->isChecked());
m_transparent = check(colors, "Transparent background", "transparent", false); connect(m_border, &QCheckBox::toggled, this, [colors, borderColor](bool enabled) {
auto *background = colorRow(colors, "Background colour:", "backgroundColor", palette().color(QPalette::Window)); colors->setRowVisible(borderColor, enabled);
background->setDisabled(m_transparent->isChecked()); connect(m_transparent, &QCheckBox::toggled, background, &QWidget::setDisabled); });
colorRow(colors, "Background colour:", "backgroundColor", palette().color(QPalette::Window), true);
colorRow(colors, "Line / number colour:", "lineColor", QColor("#3daee9")); colorRow(colors, "Line / number colour:", "lineColor", QColor("#3daee9"));
layout->addWidget(appearance); layout->addWidget(appearance);
m_graphSettings = new QWidget; m_scaleLayout = new QFormLayout(m_graphSettings); m_scaleLayout->setContentsMargins(0, 0, 0, 0); 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")))); m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
syncMode(); syncOutside(); 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 *cpu = check(hoverForm, "CPU usage", "hover/cpu", true);
auto *appOptions = new QWidget; auto *appForm = new QHBoxLayout(appOptions); appForm->setContentsMargins(24, 0, 0, 0); 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); 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) 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(); 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; } QString TrayPage::scaleKey(const QString &suffix) const { return "tray/scales/" + metric().id + "/" + suffix; }
void TrayPage::loadScale() 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); 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); } 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; 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(); 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->setValue(low); m_maximum->setMinimum(m_minimum->value() + 1); m_maximum->setValue(maximum);
m_minimum->setMaximum(m_maximum->value() - 1); m_minimum->setMaximum(m_maximum->value() - 1);
@@ -187,7 +219,7 @@ TrayStyle TrayPage::iconStyle() const
}; };
TrayStyle s; TrayStyle s;
s.historyMs = m_history->currentData().toLongLong(); 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.clamp = m_outside->currentData() == "clamp"; s.overflowColor = m_overflowColor->isChecked();
s.borderColor = color("borderColor", palette().color(QPalette::WindowText)); s.borderColor = color("borderColor", palette().color(QPalette::WindowText));
s.backgroundColor = color("backgroundColor", palette().color(QPalette::Window)); 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(); } else if (metric().unit == "°C" || metric().unit == "W") { s.minimum = m_minimum->value(); s.maximum = m_maximum->value(); }
return s; 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 <QCheckBox>
#include <QFormLayout> #include <QFormLayout>
#include <functional> #include <functional>
class QSlider;
class QGridLayout;
class QGroupBox;
class QToolButton;
struct TrayConfiguration {
QString mode;
Sensor metric;
TrayStyle style;
QVariantMap values;
};
class TrayPage : public QWidget { class TrayPage : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent = nullptr); 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(); } QString mode() const { return m_mode->currentData().toString(); }
Sensor metric() const { return m_sensors[m_metric->currentIndex()]; } Sensor metric() const { return m_sensors[m_metric->currentIndex()]; }
TrayStyle iconStyle() const; TrayStyle iconStyle() const;
@@ -32,5 +44,32 @@ private:
QDoubleSpinBox *m_minimum, *m_maximum; QDoubleSpinBox *m_minimum, *m_maximum;
QFormLayout *m_scaleLayout; QFormLayout *m_scaleLayout;
QWidget *m_graphSettings; 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 // SPDX-License-Identifier: MIT
#include "valuecontrol.h" #include "valuecontrol.h"
#include "widgets.h"
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <algorithm> #include <algorithm>
ValueControl::ValueControl(QWidget *parent) : QWidget(parent), 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); auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0); 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);
}
};
+64 -31
View File
@@ -3,6 +3,7 @@
#include "legend.h" #include "legend.h"
#include "fan.h" #include "fan.h"
#include "tooltip.h" #include "tooltip.h"
#include "widgets.h"
#include <QMessageBox> #include <QMessageBox>
#include <KAuth/Action> #include <KAuth/Action>
#include <KAuth/ExecuteJob> #include <KAuth/ExecuteJob>
@@ -61,7 +62,6 @@ Window::Window() : m_settings("fedora-tools", "framework-laptop-tools"),
addPage(trayPage(), "Tray icon"); addPage(trayPage(), "Tray icon");
addPage(preferencesPage(), "Preferences"); addPage(preferencesPage(), "Preferences");
m_savedTray = m_trayPage->draft(); m_savedPreferences = preferenceValues(); 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); for (const auto &key : {"keyboard", "power", "chargeLimit", "chargeWatts", "fan"}) m_savedControls[key] = controlValue(key);
m_pendingBar = new QWidget; m_pendingBar->setObjectName("pendingChanges"); m_pendingBar = new QWidget; m_pendingBar->setObjectName("pendingChanges");
auto *bar = new QHBoxLayout(m_pendingBar); 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(); if (isVisible() && !m_sleeping && m_tabs->currentIndex() >= 1 && m_tabs->currentIndex() <= 3) refreshControls();
}); });
m_controlsTimer.start(2000); m_controlsTimer.start(2000);
m_tray = new QSystemTrayIcon(windowIcon(), this); applyTray();
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();
if (!QDBusConnection::systemBus().connect("org.freedesktop.login1", "/org/freedesktop/login1", if (!QDBusConnection::systemBus().connect("org.freedesktop.login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager", "PrepareForSleep", this, SLOT(sleepChanged(bool)))) "org.freedesktop.login1.Manager", "PrepareForSleep", this, SLOT(sleepChanged(bool))))
m_message->setText("Sleep notifications unavailable; sleep intervals cannot be marked on the battery graph."); 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 *heading = new QHBoxLayout;
auto *label = new QLabel(title); label->setObjectName("chartTitle"); auto *label = new QLabel(title); label->setObjectName("chartTitle");
auto font = label->font(); font.setPointSizeF(font.pointSizeF() + 2); label->setFont(font); auto font = label->font(); font.setPointSizeF(font.pointSizeF() + 2); label->setFont(font);
heading->addStretch();
heading->addWidget(label); heading->addWidget(label);
if (title == "Fan speed" || title == "Battery") { 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; if (title == "Fan speed") m_fanDetails = details; else m_batteryDetails = details;
} else heading->addStretch(); }
heading->addStretch();
layout->addLayout(heading); layout->addLayout(heading);
QVector<Sensor> main, extra; QVector<Sensor> main, extra;
for (const auto &sensor : sensors) { for (const auto &sensor : sensors) {
@@ -156,7 +149,7 @@ QWidget *Window::monitorPage()
{ {
auto *page = new QWidget; auto *layout = new QVBoxLayout(page); auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *historyRow = new QHBoxLayout; 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. // Logarithmic spacing, rounded to whole minutes for a readable selection.
history->setRange(0, 1000); history->setRange(0, 1000);
const auto savedMinutes = std::clamp(m_settings.value("monitor/historyMinutes", 1440).toInt(), 5, 1440); 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); layout->addLayout(historyRow); layout->addWidget(stretch);
m_frequencyChart = new Chart("MHz"); m_temperatureChart = new Chart("°C"); m_batteryChart = new Chart("%"); 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)); 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"); m_fanChart = new Chart("RPM");
auto *fanGroup = sensorGroup("Fan speed", m_fanChart, {{"fan", "Fan speed", {}, "RPM"}}, false); auto *fanGroup = sensorGroup("Fan speed", m_fanChart, {{"fan", "Fan speed", {}, "RPM"}}, false);
layout->addWidget(fanGroup); layout->addWidget(fanGroup);
@@ -176,7 +171,7 @@ QWidget *Window::monitorPage()
auto *batteryGroup = sensorGroup("Battery", m_batteryChart, auto *batteryGroup = sensorGroup("Battery", m_batteryChart,
{{"battery", "Charge level", {}, "%"}, {"battery-rate", "Charge / discharge rate", {}, "W"}}, false); {{"battery", "Charge level", {}, "%"}, {"battery-rate", "Charge / discharge rate", {}, "W"}}, false);
layout->addWidget(batteryGroup); 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] { auto updateHistory = [this, charts, history, stretch, span] {
const int minutes = qRound(5 * std::pow(288., history->value() / 1000.)); const int minutes = qRound(5 * std::pow(288., history->value() / 1000.));
span->setText(minutes < 60 ? QString("%1 min").arg(minutes) span->setText(minutes < 60 ? QString("%1 min").arg(minutes)
@@ -197,12 +192,12 @@ QWidget *Window::monitorPage()
QWidget *Window::lightingPage() QWidget *Window::lightingPage()
{ {
auto *page = new QWidget; auto *layout = new QVBoxLayout(page); 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"); m_keyboard = spin(0, 100, "%"); m_keyboard->setObjectName("keyboardBrightness");
keys->addRow("Keyboard brightness:", m_keyboard); 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.")); 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); 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_power = spin(1, 100, "%"); m_power->setObjectName("powerBrightness");
m_powerAuto = new QCheckBox("Automatic brightness"); m_powerAuto->setObjectName("powerAuto"); m_powerAuto = new QCheckBox("Automatic brightness"); m_powerAuto->setObjectName("powerAuto");
lights->addRow(m_powerAuto); lights->addRow("Power-button brightness:", m_power); lights->addRow(m_powerAuto); lights->addRow("Power-button brightness:", m_power);
@@ -243,7 +238,7 @@ QWidget *Window::coolingPage()
QWidget *Window::batteryPage() QWidget *Window::batteryPage()
{ {
auto *page = new QWidget; auto *layout = new QVBoxLayout(page); 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"); m_charge = spin(50, 100, "%"); m_charge->setValue(100); m_charge->setObjectName("chargeLimit");
form->addRow("Limit battery:", m_charge); form->addRow("Limit battery:", m_charge);
m_watts = spin(0, 75, " W"); m_watts->setSpecialValueText("Firmware default"); m_watts->setObjectName("chargeWatts"); 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() QWidget *Window::trayPage()
{ {
QVector<Sensor> sensors{{"cpu-usage", "CPU usage", {}, "%"}}; 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"}}; sensors += QVector<Sensor>{{"battery", "Battery charge", {}, "%"}, {"battery-rate", "Battery rate", {}, "W"}};
m_trayPage = new TrayPage(m_settings, sensors); m_trayPage = new TrayIconsPage(m_settings, sensors);
connect(m_trayPage, &TrayPage::settingsChanged, this, &Window::updatePendingBar); connect(m_trayPage, &TrayIconsPage::settingsChanged, this, &Window::updatePendingBar);
return m_trayPage; 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() void Window::updateTray()
{ {
const auto metric = m_trayMetric;
if (m_trayMode == "icon") m_tray->setIcon(windowIcon());
else {
const auto now = QDateTime::currentMSecsSinceEpoch(); const auto now = QDateTime::currentMSecsSinceEpoch();
const double since = now - (m_trayMode == "graph" ? m_trayStyle.historyMs : 0); 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; QVector<QPointF> history;
if (metric.id == "cpu-usage") if (metric.id == "cpu-usage")
for (auto it = historyStart(m_usageHistory, since); it != m_usageHistory.cend(); ++it) history.append(*it); 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 == "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); 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)); 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 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."; if (m_settings.status() != QSettings::NoError) return "Could not save application settings.";
m_savedTray = tray; m_savedPreferences = preferences; m_savedTray = tray; m_savedPreferences = preferences;
m_fastTimer.start(preferences["sampling/fast"].toInt()); m_batteryTimer.start(preferences["sampling/battery"].toInt()); 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 {}; return {};
} }
void Window::finishSave() void Window::finishSave()
@@ -493,7 +519,7 @@ void Window::sample()
{ {
if (m_sleeping) return; if (m_sleeping) return;
m_values.clear(); m_values.clear();
m_cpuPage->refreshStatus(); if (m_cpuPage->isVisible()) m_cpuPage->refreshStatus();
auto sampleSensors = [&](const QVector<Sensor> &sensors, Chart *chart) { auto sampleSensors = [&](const QVector<Sensor> &sensors, Chart *chart) {
QMap<QString, double> values; QMap<QString, double> values;
for (const auto &sensor : sensors) { for (const auto &sensor : sensors) {
@@ -503,6 +529,9 @@ void Window::sample()
chart->sample(values, m_fastTimer.interval()); chart->sample(values, m_fastTimer.interval());
}; };
sampleSensors(m_frequencies, m_frequencyChart); sampleSensors(m_temperatures, m_temperatureChart); 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 QString ec = ecHwmon();
const auto rpm = readNumber(ec + "/fan1_input"); const auto rpm = readNumber(ec + "/fan1_input");
const auto mode = readNumber(ec + "/pwm1_enable"); const auto mode = readNumber(ec + "/pwm1_enable");
@@ -518,7 +547,10 @@ void Window::sample()
const auto battery = batteryStatus(); const auto battery = batteryStatus();
const auto usage = m_cpuUsage.sample(readText("/proc/stat")); const auto usage = m_cpuUsage.sample(readText("/proc/stat"));
if (usage) m_values["cpu-usage"] = *usage; 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(); m_topAppText.clear();
for (const auto &app : m_processUsage.sample(m_cpuUsage.totalDelta())) for (const auto &app : m_processUsage.sample(m_cpuUsage.totalDelta()))
m_topAppText << app.name + QString(": %1 %").arg(app.percent, 0, 'f', 1); m_topAppText << app.name + QString(": %1 %").arg(app.percent, 0, 'f', 1);
@@ -551,9 +583,10 @@ void Window::sampleBattery()
void Window::sleepChanged(bool sleeping) void Window::sleepChanged(bool sleeping)
{ {
m_sleeping = 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()); chart->setPowerState(sleeping, sleeping || chart != m_batteryChart ? std::nullopt : onAcPower());
m_cpuUsage.reset(); m_cpuUsage.reset();
m_powerMonitor.reset();
m_processUsage.reset(); m_topAppText.clear(); m_processUsage.reset(); m_topAppText.clear();
if (!sleeping) { sample(); sampleBattery(); } if (!sleeping) { sample(); sampleBattery(); }
} }
+8 -6
View File
@@ -5,6 +5,7 @@
#include "tray.h" #include "tray.h"
#include "traypage.h" #include "traypage.h"
#include "processusage.h" #include "processusage.h"
#include "power.h"
#include <QMainWindow> #include <QMainWindow>
#include <QSettings> #include <QSettings>
#include <QTimer> #include <QTimer>
@@ -15,6 +16,7 @@
class QGroupBox; class QGroupBox;
class QTabWidget; class QTabWidget;
class QPushButton; class QPushButton;
class QMenu;
class Window : public QMainWindow { class Window : public QMainWindow {
Q_OBJECT Q_OBJECT
@@ -30,6 +32,7 @@ private:
QWidget *preferencesPage(); QWidget *preferencesPage();
QWidget *trayPage(); QWidget *trayPage();
void updateTray(); void updateTray();
void applyTray();
void sample(); void sample();
void sampleBattery(); void sampleBattery();
void request(const QVariantMap &arguments, bool inspect = false); void request(const QVariantMap &arguments, bool inspect = false);
@@ -48,18 +51,20 @@ private:
QTimer m_fastTimer, m_batteryTimer, m_controlsTimer; QTimer m_fastTimer, m_batteryTimer, m_controlsTimer;
QTabWidget *m_tabs; QTabWidget *m_tabs;
QVector<Sensor> m_temperatures, m_frequencies; QVector<Sensor> m_temperatures, m_frequencies;
PowerMonitor m_powerMonitor;
QMap<QString, double> m_values; QMap<QString, double> m_values;
CpuUsage m_cpuUsage; CpuUsage m_cpuUsage;
ProcessUsage m_processUsage; ProcessUsage m_processUsage;
QStringList m_topAppText; QStringList m_topAppText;
Samples m_usageHistory; 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_batteryDetails, *m_fanDetails;
QLabel *m_message, *m_fanReadout, *m_firmwareReadout; QLabel *m_message, *m_fanReadout, *m_firmwareReadout;
QString m_batteryText; QString m_batteryText;
QVector<QWidget *> m_controlPages; QVector<QWidget *> m_controlPages;
CpuPage *m_cpuPage; 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; ValueControl *m_keyboard, *m_power, *m_charge, *m_watts, *m_duty;
QCheckBox *m_powerAuto; QCheckBox *m_powerAuto;
QLabel *m_fanWarning; QLabel *m_fanWarning;
@@ -75,11 +80,8 @@ private:
QWidget *m_pendingBar = nullptr; QWidget *m_pendingBar = nullptr;
QPushButton *m_saveButton, *m_undoButton; QPushButton *m_saveButton, *m_undoButton;
bool m_saving = false; bool m_saving = false;
Sensor m_trayMetric;
QString m_trayMode;
TrayStyle m_trayStyle;
QComboBox *m_fanMode; QComboBox *m_fanMode;
TrayPage *m_trayPage; TrayIconsPage *m_trayPage;
int m_chargeLimit = 100; int m_chargeLimit = 100;
bool m_chargeOverride = false; bool m_chargeOverride = false;
bool m_busy = false; bool m_busy = false;
@@ -2,6 +2,7 @@
#include "hardware.h" #include "hardware.h"
#include "fan.h" #include "fan.h"
#include "cpu.h" #include "cpu.h"
#include "power.h"
#include <QTest> #include <QTest>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QDir> #include <QDir>
@@ -14,6 +15,76 @@ class HardwareTest : public QObject {
QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write(value), value.size()); QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write(value), value.size());
} }
private Q_SLOTS: 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() { void modelGate() {
QTemporaryDir root; QTemporaryDir root;
QVERIFY(!compatibilityError(root.path()).isEmpty()); QVERIFY(!compatibilityError(root.path()).isEmpty());
+426 -22
View File
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
#include "window.h" #include "window.h"
#include <QMenu>
#include <QPainter>
#include <QMouseEvent>
#include <QTest> #include <QTest>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QTabWidget> #include <QTabWidget>
@@ -21,6 +24,11 @@
#include "processusage.h" #include "processusage.h"
#include <QFile> #include <QFile>
#include <QDir> #include <QDir>
#include <QGroupBox>
#include <QScrollArea>
#include <QScrollBar>
#include "widgets.h"
#include <QColorDialog>
class WindowTest : public QObject { class WindowTest : public QObject {
Q_OBJECT Q_OBJECT
@@ -99,9 +107,24 @@ private Q_SLOTS:
auto *outside = page.findChild<QComboBox *>("trayOutside"); auto *outside = page.findChild<QComboBox *>("trayOutside");
QVERIFY(graph->isHidden()); QVERIFY(graph->isHidden());
mode->setCurrentIndex(mode->findData("graph")); QVERIFY(graph->isVisible()); QVERIFY(overflow->isVisible()); 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("hide")); QVERIFY(overflow->isHidden());
outside->setCurrentIndex(outside->findData("clamp")); QVERIFY(overflow->isVisible()); outside->setCurrentIndex(outside->findData("clamp")); QVERIFY(overflow->isVisible());
auto *cpu = page.findChild<QCheckBox *>("hover/cpu"); 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"); auto *app = page.findChild<QSpinBox *>("hover/topApps");
QCOMPARE(app->minimum(), 0); QCOMPARE(app->maximum(), 3); QCOMPARE(app->minimum(), 0); QCOMPARE(app->maximum(), 3);
const auto saved = page.draft(); const auto saved = page.draft();
@@ -117,6 +140,31 @@ private Q_SLOTS:
QVERIFY(!migrated.draft().contains("tray/hover/topApp")); QVERIFY(!migrated.draft().contains("tray/hover/topApp"));
QCOMPARE(tooltipAppCount({{"tray/hover/topApps", 3}, {"tray/hover/topApp", false}}), 3); 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() { void liveProcessSampling() {
CpuUsage cpu; ProcessUsage processes; CpuUsage cpu; ProcessUsage processes;
cpu.sample(readText("/proc/stat")); cpu.sample(readText("/proc/stat"));
@@ -131,12 +179,152 @@ private Q_SLOTS:
previous = app.percent; 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() { void timeWeightedColumns() {
const auto connected = [](const QPointF &, const QPointF &) { return true; }; const auto connected = [](const QPointF &, const QPointF &) { return true; };
const QVector<QPointF> points{{0, 0}, {2, 10}, {10, 10}}; const QVector<QPointF> points{{0, 0}, {2, 10}, {10, 10}};
auto columns = timeAverages(points.begin(), points.end(), 0, 10, 2, connected); auto columns = timeAverages(points.begin(), points.end(), 0, 10, 2, connected);
QCOMPARE(columns.size(), 2); QCOMPARE(columns.size(), 2);
QCOMPARE(columns[0].mean, 8.); QCOMPARE(columns[0].maximum, 10.); 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.); QCOMPARE(columns[1].mean, 10.);
columns = timeAverages(points.begin(), points.end(), 1, 3, 1, connected); columns = timeAverages(points.begin(), points.end(), 1, 3, 1, connected);
QCOMPARE(columns[0].mean, 8.75); // Segment clipped and weighted at both pixel edges. 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) { for (const auto &column : columns) {
QVERIFY(std::abs(column.mean - (column.begin + column.end - 10)) < 1e-10); QVERIFY(std::abs(column.mean - (column.begin + column.end - 10)) < 1e-10);
QVERIFY(std::abs(column.maximum - (column.end * 2 - 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}}; const QVector<QPointF> gaps{{0, 0}, {2, 10}, {3, NAN}, {4, 10}, {6, 0}};
columns = timeAverages(gaps.begin(), gaps.end(), 0, 6, 1, connected); 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.); QCOMPARE(columns[0].mean, 5.); QCOMPARE(columns[1].mean, 5.);
const auto line = averageLine(columns); QCOMPARE(columns[0].end, 2.); QCOMPARE(columns[1].begin, 4.); // Gaps stay separate even in one pixel.
QVERIFY(std::isnan(line[3].y())); // Never bridge a gap, even inside one pixel.
columns = timeAverages(points.begin(), points.end(), 0, 10, 1, columns = timeAverages(points.begin(), points.end(), 0, 10, 1,
[](const QPointF &, const QPointF &b) { return b.x() < 10; }); [](const QPointF &, const QPointF &b) { return b.x() < 10; });
QCOMPARE(columns.size(), 1); QCOMPARE(columns[0].end, 2.); // Explicit suspend break. 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() { void dayHistoryAndStretch() {
Chart chart("MHz"); chart.addSeries("hidden"); Chart chart("MHz"); chart.addSeries("hidden");
const qint64 start = 100000000; const qint64 start = 100000000;
@@ -187,6 +428,7 @@ private Q_SLOTS:
} }
void monitorHistorySettings() { void monitorHistorySettings() {
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path());
{ {
Window window; Window window;
auto *slider = window.findChild<QSlider *>("monitorHistory"); QVERIFY(slider); auto *slider = window.findChild<QSlider *>("monitorHistory"); QVERIFY(slider);
@@ -208,10 +450,10 @@ private Q_SLOTS:
TrayPage tray(settings, {{"cpu-usage", "CPU usage", {}, "%"}}); TrayPage tray(settings, {{"cpu-usage", "CPU usage", {}, "%"}});
QCOMPARE(tray.iconStyle().historyMs, qint64(60000)); // Retired choices use the new default. 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); Chart chart("%"); chart.resize(600, 200); chart.addSeries("sensor"); chart.setSelected("sensor", true);
const qint64 start = 100000000; 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) for (int i = 0; i <= 172800; ++i)
chart.sample({{"sensor", i == 43200 ? 100. : 10.}}, 500, start + i * 500); chart.sample({{"sensor", i == 43200 ? 100. : 10.}}, 500, start + i * 500);
QCOMPARE(chart.history("sensor").size(), 172801); QCOMPARE(chart.history("sensor").size(), 172801);
@@ -222,8 +464,7 @@ private Q_SLOTS:
bool peak = false; bool peak = false;
for (int y = 15; y < 60; ++y) for (int x = 100; x < 550; ++x) { for (int y = 15; y < 60; ++y) for (int x = 100; x < 550; ++x) {
const auto colour = image.pixelColor(x, y); const auto colour = image.pixelColor(x, y);
QVERIFY(colour != chart.color("sensor")); peak |= colour == chart.color("sensor");
peak |= colour.blue() > colour.red() + 10 && colour.green() > colour.red() + 5;
} }
QVERIFY(peak); QVERIFY(peak);
chart.setHistoryWindow(300000, true); chart.setHistoryWindow(300000, true);
@@ -236,12 +477,19 @@ private Q_SLOTS:
QCOMPARE(freq.minimum, 0.); QCOMPARE(freq.maximum, 6000.); QCOMPARE(freq.step, 2000.); QCOMPARE(freq.minimum, 0.); QCOMPARE(freq.maximum, 6000.); QCOMPARE(freq.step, 2000.);
const auto rate = AxisTicks::covering(-13, 42, 4); const auto rate = AxisTicks::covering(-13, 42, 4);
QCOMPARE(rate.minimum, -20.); QCOMPARE(rate.maximum, 60.); QCOMPARE(rate.step, 20.); 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(timeTickStep(3600000, 3), 1800000.);
QCOMPARE(ageLabel(7200000), QString("2h")); QCOMPARE(timeTickStep(240000, 8), 30000.);
QCOMPARE(ageLabel(1200000), QString("20min")); QCOMPARE(timeTickStep(60000, 8), 12000.); // 0.2 min.
QCOMPARE(ageLabel(5000), QString("5s")); QCOMPARE(ageLabel(7200000, 7200000), QString("2h"));
QCOMPARE(ageLabel(0), QString("Now")); 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() { void historyHoverAndSleep() {
Chart chart("%"); chart.addSeries("battery", "Charge level"); chart.setSelected("battery", true); Chart chart("%"); chart.addSeries("battery", "Charge level"); chart.setSelected("battery", true);
@@ -266,6 +514,64 @@ private Q_SLOTS:
chart.sample({}, 30000, historyRetentionMs + 230000); chart.sample({}, 30000, historyRetentionMs + 230000);
QVERIFY(!chart.readingAt(106000).contains("Asleep")); // Annotations expire with samples. 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() { void clickableLegend() {
Chart chart("MHz"); chart.addSeries("cpu"); chart.setSelected("cpu", true); Chart chart("MHz"); chart.addSeries("cpu"); chart.setSelected("cpu", true);
Legend legend(&chart, {{"cpu", "CPU average", {}, "MHz"}}); 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, 3), QColor(Qt::red));
QCOMPARE(image.pixelColor(32, 32), QColor(Qt::black)); QCOMPARE(image.pixelColor(32, 32), QColor(Qt::black));
style.clamp = false; image = render(); QCOMPARE(image.pixelColor(32, 3), 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)); style.clamp = true; style.overflowColor = false; image = render(); QCOMPARE(image.pixelColor(32, 1), QColor(Qt::green));
QVERIFY(trayPlotRect(true).width() < trayPlotRect(false).width()); QVERIFY(trayPlotRect(true).width() < trayPlotRect(false).width());
style.fill = true; style.fill = true;
@@ -340,7 +646,7 @@ private Q_SLOTS:
QCOMPARE(telemetryIcon(false, points, "%", style, 120000).pixmap(64, 64).toImage(), number); QCOMPARE(telemetryIcon(false, points, "%", style, 120000).pixmap(64, 64).toImage(), number);
QCOMPARE(image.pixelColor(60, 32), QColor(Qt::green)); QCOMPARE(image.pixelColor(60, 32), QColor(Qt::green));
} }
void traySoftensPeaks() { void trayShowsPeakColumns() {
TrayStyle style; style.border = false; style.backgroundColor = Qt::black; TrayStyle style; style.border = false; style.backgroundColor = Qt::black;
style.lineColor = Qt::green; style.fill = false; style.historyMs = 300000; style.lineColor = Qt::green; style.fill = false; style.historyMs = 300000;
QVector<QPointF> points; QVector<QPointF> points;
@@ -351,8 +657,7 @@ private Q_SLOTS:
bool band = false; bool band = false;
for (int y = 4; y < 30; ++y) for (int x = 20; x < 45; ++x) { for (int y = 4; y < 30; ++y) for (int x = 20; x < 45; ++x) {
const auto colour = image.pixelColor(x, y); const auto colour = image.pixelColor(x, y);
QVERIFY(colour != QColor(Qt::green)); band |= colour == QColor(Qt::green);
band |= colour.green() > 0 && colour.green() < 100;
} }
QVERIFY(band); QVERIFY(band);
bool mean = false; bool mean = false;
@@ -446,6 +751,55 @@ private Q_SLOTS:
chart.sample({{"cpu", 2500}}, 1000, now + 1000); chart.sample({{"cpu", 2500}}, 1000, now + 1000);
QVERIFY(chart.grab().toImage() != before); 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() { void sliderAndNumberStaySynchronized() {
ValueControl control; ValueControl control;
control.setRange(400, 4800); control.setSuffix(" MHz"); control.setSingleStep(100); control.setRange(400, 4800); control.setSuffix(" MHz"); control.setSingleStep(100);
@@ -469,9 +823,33 @@ private Q_SLOTS:
slider->setValue(0); slider->setValue(0);
QCOMPARE(number->text(), QString("Firmware default")); 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() { void pagesAndReadOnlyStartup() {
QTemporaryDir config; QTemporaryDir config;
qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path());
QSettings settings("fedora-tools", "framework-laptop-tools"); QSettings settings("fedora-tools", "framework-laptop-tools");
settings.setValue("sampling/fast", 0); settings.setValue("sampling/fast", 0);
settings.setValue("sampling/battery", -5); settings.setValue("sampling/battery", -5);
@@ -484,7 +862,31 @@ private Q_SLOTS:
QCOMPARE(tabs->tabText(3), QString("Battery")); QCOMPARE(tabs->tabText(3), QString("Battery"));
QCOMPARE(tabs->tabText(4), QString("CPU")); QCOMPARE(tabs->tabText(4), QString("CPU"));
QCOMPARE(tabs->tabText(5), QString("Tray icon")); 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()); for (auto *toggle : window.findChildren<QToolButton *>("extraTemperatureSensors")) QVERIFY(!toggle->isChecked());
const QString monitor = qEnvironmentVariable("FRAMEWORK_TOOLS_MONITOR_SCREENSHOT"); const QString monitor = qEnvironmentVariable("FRAMEWORK_TOOLS_MONITOR_SCREENSHOT");
if (!monitor.isEmpty()) QVERIFY(window.grab().save(monitor)); if (!monitor.isEmpty()) QVERIFY(window.grab().save(monitor));
@@ -568,6 +970,7 @@ private Q_SLOTS:
} }
void sharedSaveAndUndo() { void sharedSaveAndUndo() {
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path());
QSettings settings("fedora-tools", "framework-laptop-tools"); QSettings settings("fedora-tools", "framework-laptop-tools");
Window window; window.show(); Window window; window.show();
auto *bar = window.findChild<QWidget *>("pendingChanges"); QVERIFY(bar); auto *bar = window.findChild<QWidget *>("pendingChanges"); QVERIFY(bar);
@@ -606,10 +1009,10 @@ private Q_SLOTS:
hoverApps->setValue(3); hoverApps->setValue(3);
QTRY_VERIFY(save->isEnabled()); save->click(); QTRY_VERIFY(save->isEnabled()); save->click();
QVERIFY(bar->isHidden()); QCOMPARE(settings.value("sampling/fast").toInt(), 2000); 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/icons/1/mode").toString(), QString("number")); QVERIFY(QFile::exists(path));
QCOMPARE(settings.value("tray/historyMs").toInt(), 60000); QCOMPARE(settings.value("tray/icons/1/historyMs").toInt(), 60000);
QVERIFY(!settings.value("tray/hover/cpu").toBool()); QVERIFY(!settings.value("tray/icons/1/hover/cpu").toBool());
QCOMPARE(settings.value("tray/hover/topApps").toInt(), 3); QCOMPARE(settings.value("tray/icons/1/hover/topApps").toInt(), 3);
QVERIFY(!trayIcon->toolTip().startsWith("CPU ")); QVERIFY(!trayIcon->toolTip().startsWith("CPU "));
hoverCpu->setChecked(true); hoverCpu->setChecked(true);
hoverApps->setValue(1); hoverApps->setValue(1);
@@ -621,7 +1024,7 @@ private Q_SLOTS:
QVERIFY(!hoverCpu->isChecked()); QVERIFY(!hoverCpu->isChecked());
QCOMPARE(hoverApps->value(), 3); QCOMPARE(hoverApps->value(), 3);
// Hover is a timestamp shared by every graph, not the same screen coordinate. // 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; const qint64 time = QDateTime::currentMSecsSinceEpoch() - 1000;
charts.first()->hovered(time); charts.first()->hovered(time);
for (auto *chart : charts) QCOMPARE(chart->hoverTime(), std::optional<qint64>(time)); for (auto *chart : charts) QCOMPARE(chart->hoverTime(), std::optional<qint64>(time));
@@ -630,6 +1033,7 @@ private Q_SLOTS:
} }
void lowFanConfirmationCanBeCancelled() { void lowFanConfirmationCanBeCancelled() {
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, config.path());
Window window; window.show(); Window window; window.show();
auto *save = window.findChild<QPushButton *>("saveAll"); auto *save = window.findChild<QPushButton *>("saveAll");
QTRY_VERIFY(save->isEnabled()); QTRY_VERIFY(save->isEnabled());
@@ -57,6 +57,8 @@ patched_checksum=$(sha256sum "$target")
run_tool apply >/dev/null run_tool apply >/dev/null
[[ $(sha256sum "$target") == "$patched_checksum" ]] [[ $(sha256sum "$target") == "$patched_checksum" ]]
run_tool revert >/dev/null
cmp "$fixture" "$target"
run_tool revert >/dev/null run_tool revert >/dev/null
cmp "$fixture" "$target" cmp "$fixture" "$target"
@@ -67,5 +69,20 @@ if run_tool apply >/dev/null 2>&1; then
exit 1 exit 1
fi fi
[[ $(sha256sum "$target") == "$unsupported_checksum" ]] [[ $(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' printf 'plasma-always-show-unlock tests passed\n'
@@ -96,6 +96,7 @@ installed_base() {
} }
show_status() { show_status() {
local enabled=1
if ! rpm -q kscreenlocker >/dev/null 2>&1; then if ! rpm -q kscreenlocker >/dev/null 2>&1; then
printf 'KScreenLocker is not installed.\n' printf 'KScreenLocker is not installed.\n'
return 1 return 1
@@ -103,18 +104,16 @@ show_status() {
if is_workaround_package -q kscreenlocker; then if is_workaround_package -q kscreenlocker; then
printf 'Fingerprint workaround: enabled\n' 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 elif is_legacy_workaround; then
printf 'Fingerprint workaround: enabled (legacy unmanaged build)\n' 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 else
enabled=0
printf 'Fingerprint workaround: disabled\n' printf 'Fingerprint workaround: disabled\n'
printf 'Installed package: kscreenlocker-%s.%s\n' \ fi
"$(installed_value '%{EVR}')" "$(installed_value '%{ARCH}')" printf 'Installed package: kscreenlocker-%s\n' \
"$(installed_value '%{EVR}.%{ARCH}')"
if (( enabled )); then
printf 'Based on Fedora package: kscreenlocker-%s\n' "$(installed_base)"
fi fi
} }
@@ -1,6 +1,6 @@
Name: plasma-fingerprint-workaround Name: plasma-fingerprint-workaround
Version: 0.1.0 Version: 0.1.0
Release: 4%{?dist} Release: 5%{?dist}
Summary: Opt-in patched KScreenLocker for fingerprint recovery after suspend Summary: Opt-in patched KScreenLocker for fingerprint recovery after suspend
License: MIT License: MIT
@@ -51,6 +51,9 @@ install -D -m 0644 %{SOURCE3} \
%{_datadir}/plasma-fingerprint-workaround/payload.conf %{_datadir}/plasma-fingerprint-workaround/payload.conf
%changelog %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 * Sat Sep 12 2026 fedora-tools contributors - 0.1.0-4
- Update the opt-in payload to Fedora KScreenLocker 6.7.5 - Update the opt-in payload to Fedora KScreenLocker 6.7.5
@@ -8,10 +8,19 @@ payload_config=$2
bash -n "$controller" bash -n "$controller"
"$controller" --help | grep -q '^ .* enable \[--rpm PATH\] \[--force\] \[--yes\]$' "$controller" --help | grep -q '^ .* enable \[--rpm PATH\] \[--force\] \[--yes\]$'
if "$controller" unknown-command >/dev/null 2>&1; then reject() {
printf 'Unknown command unexpectedly succeeded.\n' >&2 if "$controller" "$@" >/dev/null 2>&1; then
printf 'Invalid invocation unexpectedly succeeded: %s\n' "$*" >&2
exit 1 exit 1
fi fi
}
reject
reject unknown-command
reject status --yes
reject enable --rpm
reject enable --unknown
reject disable --force
payload_url= payload_url=
payload_sha256= payload_sha256=
+4 -3
View File
@@ -9,7 +9,7 @@ include(KDEInstallDirs)
include(KDECMakeSettings) include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE) include(KDECompilerSettings NO_POLICY_SCOPE)
include(CTest) 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) find_package(KF6 REQUIRED COMPONENTS Config I18n Service KIO)
add_library(panelactionsplugin MODULE src/plugin.cpp src/panelcontroller.cpp src/settings.cpp) 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) 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 # RPM normalizes file timestamps. A content-specific URL prevents Qt from
# reusing an older settings page's disk cache after a same-day upgrade. # 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) set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS src/SettingsWindow.qml src/ConfigGeneral.qml)
file(SHA256 src/SettingsWindow.qml window_hash) file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/src/SettingsWindow.qml" window_hash)
file(SHA256 src/ConfigGeneral.qml form_hash) file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/src/ConfigGeneral.qml" form_hash)
string(SHA256 ui_hash "${window_hash}${form_hash}") string(SHA256 ui_hash "${window_hash}${form_hash}")
set(settings_ui_dir "plasma-panel-actions/${ui_hash}") set(settings_ui_dir "plasma-panel-actions/${ui_hash}")
target_compile_definitions(plasma-panel-actions PRIVATE SETTINGS_UI_DIR="${settings_ui_dir}") 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 package/ DESTINATION ${KDE_INSTALL_DATADIR}/plasma/plasmoids/se.ajpanton.panelactions)
install(DIRECTORY controller/ DESTINATION ${KDE_INSTALL_DATADIR}/plasma/plasmoids/se.ajpanton.panelactions.controller) install(DIRECTORY controller/ DESTINATION ${KDE_INSTALL_DATADIR}/plasma/plasmoids/se.ajpanton.panelactions.controller)
if(BUILD_TESTING) if(BUILD_TESTING)
find_package(Qt6 6.8 REQUIRED COMPONENTS Test QuickTest)
add_executable(test-panelsettings tests/test-settings.cpp src/settings.cpp) 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_link_libraries(test-panelsettings PRIVATE Qt6::Test KF6::ConfigCore KF6::Service KF6::KIOGui)
target_include_directories(test-panelsettings PRIVATE src) target_include_directories(test-panelsettings PRIVATE src)
@@ -1,6 +1,6 @@
Name: plasma-panel-actions Name: plasma-panel-actions
Version: 0.1.0 Version: 0.1.0
Release: 10%{?dist} Release: 11%{?dist}
Summary: Configurable panel scrolling and empty-space click actions Summary: Configurable panel scrolling and empty-space click actions
License: MIT License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools 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 %{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-panel-actions-autostart.desktop
%{_datadir}/fedora-tools/settings/plasma-panel-actions.json %{_datadir}/fedora-tools/settings/plasma-panel-actions.json
%changelog %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 * Fri Sep 11 2026 fedora-tools contributors - 0.1.0-10
- Use Plasma's content boundaries for both clicks and scrolling at panel edges - Use Plasma's content boundaries for both clicks and scrolling at panel edges
* Wed Sep 09 2026 fedora-tools contributors - 0.1.0-9 * Wed Sep 09 2026 fedora-tools contributors - 0.1.0-9
+2 -1
View File
@@ -25,7 +25,8 @@ Kirigami.ApplicationWindow {
applications: panelSettings.applications applications: panelSettings.applications
Layout.fillWidth: true Layout.fillWidth: true
function load() { 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() Component.onCompleted: load()
} }
+11 -13
View File
@@ -20,11 +20,7 @@ PanelController::PanelController(QQuickItem *parent) : QQuickItem(parent)
} }
m_pendingButton = Qt::NoButton; m_pendingButton = Qt::NoButton;
}); });
connect(this, &PanelController::settingsChanged, this, [this] { connect(this, &PanelController::settingsChanged, this, &PanelController::resetInteraction);
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
m_pressedButton = Qt::NoButton;
});
connect(this, &QQuickItem::windowChanged, this, [this](QQuickWindow *window) { connect(this, &QQuickItem::windowChanged, this, [this](QQuickWindow *window) {
if (m_panel) { if (m_panel) {
m_panel->removeEventFilter(this); m_panel->removeEventFilter(this);
@@ -33,10 +29,7 @@ PanelController::PanelController(QQuickItem *parent) : QQuickItem(parent)
if (m_panel) { if (m_panel) {
m_panel->installEventFilter(this); m_panel->installEventFilter(this);
} }
m_remainder = 0; resetInteraction();
m_pressedButton = Qt::NoButton;
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
}); });
} }
@@ -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) QString PanelController::areaAt(const QPointF &position)
{ {
// Dragging outside the panel must not project back onto one of its widgets. // 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) bool PanelController::eventFilter(QObject *object, QEvent *event)
{ {
if (object != m_panel || !m_active) { if (object != m_panel || !m_active) {
m_remainder = 0; resetInteraction();
m_pressedButton = Qt::NoButton;
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
return false; return false;
} }
if (event->type() == QEvent::Wheel) { if (event->type() == QEvent::Wheel) {
@@ -27,6 +27,7 @@ Q_SIGNALS:
protected: protected:
bool eventFilter(QObject *object, QEvent *event) override; bool eventFilter(QObject *object, QEvent *event) override;
private: private:
void resetInteraction();
QString areaAt(const QPointF &position); QString areaAt(const QPointF &position);
QPointer<QQuickWindow> m_panel; QPointer<QQuickWindow> m_panel;
bool m_active = false; bool m_active = false;
@@ -135,6 +135,21 @@ private Q_SLOTS:
QCOMPARE(window.delivered, 4); QCOMPARE(window.delivered, 4);
QCOMPARE(actions.count(), 1); 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() void draggingCancelsClickEvenWhenReturning()
{ {
QJSEngine engine; QJSEngine engine;
@@ -23,6 +23,7 @@ find_package(XKB REQUIRED)
# KWin caches both QML components and directory listings for its whole session. # KWin caches both QML components and directory listings for its whole session.
# Changed helper code needs a new directory, not just a new filename. # 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) file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/TaskbarGeometry.qml" geometry_hash)
set(geometry_dir "plasma-task-group-shortcuts/${geometry_hash}") set(geometry_dir "plasma-task-group-shortcuts/${geometry_hash}")
configure_file(TaskbarGeometry.qml "qml/${geometry_hash}/TaskbarGeometry.qml" COPYONLY) configure_file(TaskbarGeometry.qml "qml/${geometry_hash}/TaskbarGeometry.qml" COPYONLY)
@@ -1,6 +1,6 @@
Name: plasma-task-group-shortcuts Name: plasma-task-group-shortcuts
Version: 0.1.0 Version: 0.1.0
Release: 20%{?dist} Release: 21%{?dist}
Summary: Windows-like application-group shortcuts for the Plasma Task Manager Summary: Windows-like application-group shortcuts for the Plasma Task Manager
License: MIT License: MIT
@@ -62,6 +62,10 @@ install -Dpm 0644 %{SOURCE2} \
%{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop %{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop
%changelog %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 * Thu Sep 10 2026 fedora-tools contributors - 0.1.0-20
- Load updated geometry helpers without reusing KWin's cached QML - Load updated geometry helpers without reusing KWin's cached QML
@@ -43,15 +43,15 @@ QVector<int> logicalTaskRows(const QAbstractItemModel &model, bool separateLaunc
continue; 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) { for (int windowRow = 0; windowRow < model.rowCount(); ++windowRow) {
const QModelIndex window = model.index(windowRow, 0); const QModelIndex window = model.index(windowRow, 0);
if (!window.data(AbstractTasksModel::IsWindow).toBool()) { if (!window.data(AbstractTasksModel::IsWindow).toBool()) {
continue; continue;
} }
const QString launcherAppId = launcher.data(AbstractTasksModel::AppId).toString();
const QString windowAppId = window.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(); const QUrl windowUrl = window.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();
if ((!launcherAppId.isEmpty() && launcherAppId == windowAppId) if ((!launcherAppId.isEmpty() && launcherAppId == windowAppId)
|| (launcherUrl.isValid() && windowUrl.isValid() || (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) int initialCyclePosition(const QAbstractItemModel &model, int row, bool startWithFirst, bool backwards)
{ {
const QModelIndex group = model.index(row, 0); const QModelIndex group = model.index(row, 0);
if (!group.isValid()) {
return 0;
}
const int childCount = model.rowCount(group); const int childCount = model.rowCount(group);
if (childCount == 0) { if (childCount == 0) {
return 0; return 0;
@@ -31,6 +31,8 @@ void TaskSelectionTest::returnsTopLevelTask()
QCOMPARE(taskToActivate(model, 0, 0), model.index(0, 0)); QCOMPARE(taskToActivate(model, 0, 0), model.index(0, 0));
QVERIFY(!taskToActivate(model, 1, 0).isValid()); QVERIFY(!taskToActivate(model, 1, 0).isValid());
QCOMPARE(initialCyclePosition(model, 1, false, true), 0);
QCOMPARE(initialCyclePosition(model, -1, false, true), 0);
} }
void TaskSelectionTest::selectsChildByCyclePosition() void TaskSelectionTest::selectsChildByCyclePosition()
+2 -6
View File
@@ -7,15 +7,11 @@ set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
tool_dir="$repo_root/fedora-tools-settings" tool_dir="$repo_root/fedora-tools-settings"
topdir="$repo_root/rpmbuild" topdir="$repo_root/rpmbuild"
source_dir="$topdir/SOURCES/fedora-tools-settings-0.1.0"
mkdir -p "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} mkdir -p "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
rm -rf -- "$source_dir" tar -C "$tool_dir" --transform='s,^,fedora-tools-settings-0.1.0/,' -czf \
mkdir -p "$source_dir"
cp -a "$tool_dir/CMakeLists.txt" "$tool_dir/src" "$tool_dir/tests" "$source_dir/"
tar -C "$topdir/SOURCES" -czf \
"$topdir/SOURCES/fedora-tools-settings-0.1.0.tar.gz" \ "$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 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
install -m 0644 "$tool_dir/README.md" "$topdir/SOURCES/README.md" 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) repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
tool_dir="$repo_root/plasma-task-group-shortcuts" tool_dir="$repo_root/plasma-task-group-shortcuts"
topdir="$repo_root/rpmbuild" 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 "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
mkdir -p "$source_dir/tests" (
install -m 0644 "$tool_dir/CMakeLists.txt" "$source_dir/CMakeLists.txt" cd -- "$tool_dir"
install -m 0644 "$tool_dir/keyboardlayout.cpp" "$source_dir/keyboardlayout.cpp" tar --transform='s,^,plasma-task-group-shortcuts-0.1.0/,' \
install -m 0644 "$tool_dir/keyboardlayout.h" "$source_dir/keyboardlayout.h" -czf "$topdir/SOURCES/plasma-task-group-shortcuts-0.1.0.tar.gz" \
install -m 0644 "$tool_dir/main.cpp" "$source_dir/main.cpp" CMakeLists.txt *.cpp *.h *.qml *.desktop tests
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
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE" install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
install -m 0644 "$repo_root/README.md" "$topdir/SOURCES/README.md" 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 dx = axis_distance(contact.x - origin_x, state.x_absinfo)
local dy = axis_distance(contact.y - origin_y, state.y_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 end
local function movement_exceeded(state, contact, limit_mm) 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 'maximum_tap_ms = 225' "$override"
grep -q 'output_event = "KEY_F13"' "$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 TOUCHPAD_HOLD_TAP_TEST_ROOT=$test_root bash "$configurator" reset
[[ ! -e $config ]] [[ ! -e $config ]]
[[ ! -e $override ]] [[ ! -e $override ]]
+29 -27
View File
@@ -17,6 +17,9 @@ minimum_pause_ms=100
minimum_tap_ms=10 minimum_tap_ms=10
maximum_tap_ms=150 maximum_tap_ms=150
output_event=BTN_MIDDLE 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() die()
{ {
@@ -33,13 +36,13 @@ require_root()
validate() validate()
{ {
[[ $minimum_anchor_age_ms =~ ^[0-9]+$ ]] || die "minimum anchor age must be an integer" local name
[[ $minimum_pause_ms =~ ^[0-9]+$ ]] || die "minimum pause must be an integer" for name in minimum_anchor_age_ms minimum_pause_ms minimum_tap_ms maximum_tap_ms; do
[[ $minimum_tap_ms =~ ^[0-9]+$ ]] || die "minimum tap duration must be an integer" [[ ${!name} =~ ^0*([0-9]{1,4})$ ]] || die "${name//_/ } must be an integer between 0 and 2000"
[[ $maximum_tap_ms =~ ^[0-9]+$ ]] || die "maximum tap duration must be an integer" printf -v "$name" '%d' "$((10#${BASH_REMATCH[1]}))"
(( minimum_anchor_age_ms <= 2000 )) || die "minimum anchor age must not exceed 2000 ms" (( ${!name} <= 2000 )) || die "${name//_/ } must not exceed 2000 ms"
(( minimum_pause_ms <= 2000 )) || die "minimum pause must not exceed 2000 ms" done
(( maximum_tap_ms >= 1 && minimum_tap_ms <= maximum_tap_ms && maximum_tap_ms <= 2000 )) || \ (( 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" 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_]+$ ]] || \ [[ $output_event =~ ^(BTN|KEY)_[A-Z0-9_]+$ ]] || \
die "output event must be an evdev BTN_* or KEY_* name" die "output event must be an evdev BTN_* or KEY_* name"
@@ -51,7 +54,7 @@ read_config()
return 0 return 0
fi fi
while IFS='=' read -r key value; do while IFS='=' read -r key value || [[ -n $key ]]; do
case $key in case $key in
MINIMUM_ANCHOR_AGE_MS) minimum_anchor_age_ms=$value ;; MINIMUM_ANCHOR_AGE_MS) minimum_anchor_age_ms=$value ;;
MINIMUM_PAUSE_MS) minimum_pause_ms=$value ;; MINIMUM_PAUSE_MS) minimum_pause_ms=$value ;;
@@ -65,27 +68,26 @@ read_config()
validate 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() write_config()
{ {
mkdir -p "$(dirname -- "$config_file")" mkdir -p "$(dirname -- "$config_file")"
local temporary temporary_config=$(mktemp "${config_file}.XXXXXX")
temporary=$(mktemp "${config_file}.XXXXXX") print_config > "$temporary_config"
trap 'rm -f -- "$temporary"' RETURN chmod 0644 "$temporary_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" > "$temporary"
chmod 0644 "$temporary"
mv -f -- "$temporary" "$config_file"
trap - RETURN
} }
write_override() write_override()
{ {
[[ -r $template_file ]] || die "installed plugin was not found" [[ -r $template_file ]] || die "installed plugin was not found"
mkdir -p "$(dirname -- "$override_file")" mkdir -p "$(dirname -- "$override_file")"
local temporary temporary_override=$(mktemp "${override_file}.XXXXXX")
temporary=$(mktemp "${override_file}.XXXXXX")
trap 'rm -f -- "$temporary"' RETURN
awk -v age="$minimum_anchor_age_ms" -v pause="$minimum_pause_ms" \ awk -v age="$minimum_anchor_age_ms" -v pause="$minimum_pause_ms" \
-v mintap="$minimum_tap_ms" \ -v mintap="$minimum_tap_ms" \
-v maxtap="$maximum_tap_ms" \ -v maxtap="$maximum_tap_ms" \
@@ -103,10 +105,8 @@ write_override()
} }
{ print } { print }
END { if (!replaced) exit 1 } END { if (!replaced) exit 1 }
' "$template_file" > "$temporary" || die "installed plugin has an unsupported format" ' "$template_file" > "$temporary_override" || die "installed plugin has an unsupported format"
chmod 0644 "$temporary" chmod 0644 "$temporary_override"
mv -f -- "$temporary" "$override_file"
trap - RETURN
} }
usage() usage()
@@ -125,9 +125,7 @@ case $command in
show) show)
[[ $# -eq 1 ]] || die "show takes no arguments" [[ $# -eq 1 ]] || die "show takes no arguments"
read_config read_config
printf 'MINIMUM_ANCHOR_AGE_MS=%s\nMINIMUM_PAUSE_MS=%s\nMINIMUM_TAP_MS=%s\nMAXIMUM_TAP_MS=%s\nOUTPUT_EVENT=%s\n' \ print_config
"$minimum_anchor_age_ms" "$minimum_pause_ms" "$minimum_tap_ms" "$maximum_tap_ms" \
"$output_event"
;; ;;
set) set)
shift shift
@@ -166,6 +164,9 @@ case $command in
validate validate
write_config write_config
write_override 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) reset)
[[ $# -eq 1 ]] || die "reset takes no arguments" [[ $# -eq 1 ]] || die "reset takes no arguments"
@@ -178,6 +179,7 @@ case $command in
if [[ -e $config_file ]]; then if [[ -e $config_file ]]; then
read_config read_config
write_override write_override
mv -f -- "$temporary_override" "$override_file"
else else
rm -f -- "$override_file" rm -f -- "$override_file"
fi fi
+5 -1
View File
@@ -1,6 +1,6 @@
Name: touchpad-hold-tap Name: touchpad-hold-tap
Version: 0.1.0 Version: 0.1.0
Release: 10%{?dist} Release: 11%{?dist}
Summary: Hold-tap middle-click gesture for touchpads Summary: Hold-tap middle-click gesture for touchpads
License: MIT License: MIT
@@ -57,6 +57,10 @@ if [ "$1" -eq 0 ]; then
fi fi
%changelog %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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-10
- Reset stale touch contacts after an interrupted input stream - Reset stale touch contacts after an interrupted input stream