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