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