Add configurable Plasma panel scroll and click actions

This commit is contained in:
ajp_anton
2026-09-07 19:47:47 +00:00
parent ed2bb7f92c
commit f5988605c8
29 changed files with 1707 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
#include <QtQuickTest/quicktest.h>
#include <QQmlContext>
#include <QQmlEngine>
#include <QTemporaryDir>
class TestSetup : public QObject
{
Q_OBJECT
public:
TestSetup() { qputenv("XDG_CONFIG_HOME", m_config.path().toUtf8()); }
Q_INVOKABLE QString i18n(const QString &text) { return text; }
public Q_SLOTS:
void qmlEngineAvailable(QQmlEngine *engine) { engine->rootContext()->setContextObject(this); }
private:
QTemporaryDir m_config;
};
QUICK_TEST_MAIN_WITH_SETUP(panelareas, TestSetup)
#include "quicktest.moc"
@@ -0,0 +1,238 @@
// SPDX-License-Identifier: MIT
#include "panelcontroller.h"
#include <QJSEngine>
#include <QMouseEvent>
#include <QQuickWindow>
#include <QSignalSpy>
#include <QTest>
#include <QWheelEvent>
#include <QStyleHints>
using namespace Qt::StringLiterals;
class PanelWindow : public QQuickWindow
{
public:
int delivered = 0;
bool event(QEvent *event) override
{
if (event->type() == QEvent::Wheel || event->type() == QEvent::MouseButtonPress
|| event->type() == QEvent::MouseButtonRelease || event->type() == QEvent::MouseMove) {
++delivered;
return true;
}
return QQuickWindow::event(event);
}
};
class PanelControllerTest : public QObject
{
Q_OBJECT
private:
static void setup(PanelController &controller, PanelWindow &window, QJSEngine &engine)
{
controller.setParentItem(window.contentItem());
controller.setProperty("active", true);
controller.setProperty("locateArea", QVariant::fromValue(engine.evaluate(
u"(function(x,y) { return x < 100 ? 'empty' : 'tasks'; })"_s)));
controller.setProperty("scrollActions", QVariantMap{{u"empty"_s, u"volume"_s}, {u"tasks"_s, u"brightness"_s}});
}
static void wheel(PanelWindow &window, int delta, int x = 20, bool pixels = false)
{
QWheelEvent event(QPointF(x, 10), QPointF(x, 10),
pixels ? QPoint(0, delta) : QPoint(), pixels ? QPoint() : QPoint(0, delta),
Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false);
QCoreApplication::sendEvent(&window, &event);
}
static void mouse(PanelWindow &window, QEvent::Type type, int x, Qt::MouseButton button = Qt::LeftButton)
{
QMouseEvent event(type, QPointF(x, 10), QPointF(x, 10),
type == QEvent::MouseMove ? Qt::NoButton : button,
type == QEvent::MouseButtonRelease ? Qt::NoButton : button, Qt::NoModifier);
QCoreApplication::sendEvent(&window, &event);
}
private Q_SLOTS:
void wheelOverridesAndNormalBehaviour()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
QSignalSpy actions(&controller, &PanelController::actionRequested);
wheel(window, 120);
wheel(window, -120, 150);
QCOMPARE(actions.count(), 2);
QCOMPARE(actions.at(0), QVariantList({u"volume"_s, 1}));
QCOMPARE(actions.at(1), QVariantList({u"brightness"_s, -1}));
QCOMPARE(window.delivered, 0);
controller.setProperty("scrollActions", QVariantMap{{u"empty"_s, u"normal"_s}});
wheel(window, 120);
QCOMPARE(window.delivered, 1);
QCOMPARE(actions.count(), 2);
}
void smoothScrollingAndAreaChanges()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
QSignalSpy actions(&controller, &PanelController::actionRequested);
wheel(window, 60);
QCOMPARE(actions.count(), 0);
wheel(window, 60);
QCOMPARE(actions.count(), 1);
wheel(window, 60);
wheel(window, 60, 150);
QCOMPARE(actions.count(), 1); // partial gestures cannot cross areas
wheel(window, 60, 150);
QCOMPARE(actions.count(), 2);
wheel(window, 20, 20, true);
QCOMPARE(actions.count(), 2);
wheel(window, 20, 20, true);
QCOMPARE(actions.count(), 3);
}
void onlyEmptySpaceClicksAreOverridden()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{u"leftClick"_s, u"mute"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 20);
QCOMPARE(actions.count(), 0);
mouse(window, QEvent::MouseButtonRelease, 20);
QCOMPARE(actions.count(), 1);
QCOMPARE(actions.at(0), QVariantList({u"leftClick"_s}));
QCOMPARE(window.delivered, 0);
mouse(window, QEvent::MouseButtonPress, 150);
mouse(window, QEvent::MouseButtonRelease, 150);
mouse(window, QEvent::MouseButtonPress, 20, Qt::RightButton);
mouse(window, QEvent::MouseButtonRelease, 20, Qt::RightButton);
QCOMPARE(window.delivered, 4);
QCOMPARE(actions.count(), 1);
}
void draggingCancelsClickEvenWhenReturning()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{u"leftClick"_s, u"mute"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseMove, 80);
mouse(window, QEvent::MouseMove, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
QCOMPARE(actions.count(), 0);
}
void disabledAndOtherWindowsAreUntouched()
{
QJSEngine engine;
PanelWindow window;
PanelWindow popup;
PanelController controller;
setup(controller, window, engine);
QSignalSpy actions(&controller, &PanelController::actionRequested);
wheel(popup, 120);
QCOMPARE(popup.delivered, 1);
controller.setProperty("active", false);
wheel(window, 120);
QCOMPARE(window.delivered, 1);
QCOMPARE(actions.count(), 0);
controller.setParentItem(popup.contentItem());
controller.setProperty("active", true);
wheel(window, 120);
QCOMPARE(window.delivered, 2);
wheel(popup, 120);
QCOMPARE(actions.count(), 1);
}
void doubleClicks_data()
{
QTest::addColumn<int>("button");
QTest::addColumn<QString>("singleSlot");
QTest::addColumn<QString>("doubleSlot");
QTest::addColumn<bool>("precedingPress");
QTest::newRow("left") << int(Qt::LeftButton) << u"leftClick"_s << u"leftDoubleClick"_s << false;
QTest::newRow("middle") << int(Qt::MiddleButton) << u"middleClick"_s << u"middleDoubleClick"_s << false;
QTest::newRow("qt-window-left") << int(Qt::LeftButton) << u"leftClick"_s << u"leftDoubleClick"_s << true;
QTest::newRow("qt-window-middle") << int(Qt::MiddleButton) << u"middleClick"_s << u"middleDoubleClick"_s << true;
}
void doubleClicks()
{
QFETCH(int, button);
QFETCH(QString, singleSlot);
QFETCH(QString, doubleSlot);
QFETCH(bool, precedingPress);
const auto mouseButton = Qt::MouseButton(button);
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{singleSlot, u"mute"_s}, {doubleSlot, u"application"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 20, mouseButton);
mouse(window, QEvent::MouseButtonRelease, 20, mouseButton);
QCOMPARE(actions.count(), 0);
// QGuiApplication sends both Press and DblClick for the second down.
if (precedingPress) mouse(window, QEvent::MouseButtonPress, 20, mouseButton);
mouse(window, QEvent::MouseButtonDblClick, 20, mouseButton);
mouse(window, QEvent::MouseButtonRelease, 20, mouseButton);
QCOMPARE(actions.count(), 1);
QCOMPARE(actions.at(0), QVariantList({doubleSlot}));
QTest::qWait(QGuiApplication::styleHints()->mouseDoubleClickInterval() + 30);
QCOMPARE(actions.count(), 1); // no single-click action after the double
mouse(window, QEvent::MouseButtonPress, 20, mouseButton);
mouse(window, QEvent::MouseButtonRelease, 20, mouseButton);
QCOMPARE(actions.count(), 1);
QTRY_COMPARE(actions.count(), 2);
QCOMPARE(actions.at(1), QVariantList({singleSlot}));
}
void distantClicksDoNotCombine()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{u"leftClick"_s, u"mute"_s}, {u"leftDoubleClick"_s, u"overview"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 10);
mouse(window, QEvent::MouseButtonRelease, 10);
mouse(window, QEvent::MouseButtonPress, 90);
mouse(window, QEvent::MouseButtonRelease, 90);
QTRY_COMPARE(actions.count(), 2);
QCOMPARE(actions.at(0), QVariantList({u"leftClick"_s}));
QCOMPARE(actions.at(1), QVariantList({u"leftClick"_s}));
}
void doubleOnlyAndDisablingPendingClicks()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{u"leftDoubleClick"_s, u"command"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
// Some platforms deliver the second press without a DblClick event.
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
QCOMPARE(actions.count(), 1);
QCOMPARE(actions.at(0), QVariantList({u"leftDoubleClick"_s}));
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseMove, 80);
mouse(window, QEvent::MouseMove, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
QCOMPARE(actions.count(), 1); // dragging the second press cancels the double
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
controller.setProperty("active", false);
QTest::qWait(QGuiApplication::styleHints()->mouseDoubleClickInterval() + 30);
QCOMPARE(actions.count(), 1);
}
};
QTEST_MAIN(PanelControllerTest)
#include "test-panelcontroller.moc"
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# Run in a Fedora build environment with the application's dependencies.
set -euo pipefail
if (( $# != 2 )); then
printf 'Usage: %s OLD_RPM NEW_RPM\n' "$0" >&2
exit 2
fi
old_rpm=$(realpath "$1")
new_rpm=$(realpath "$2")
work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT
mkdir -p "$work/root" "$work/runtime"
export XDG_CONFIG_HOME="$work/config" XDG_CACHE_HOME="$work/cache"
export XDG_DATA_HOME="$work/data" XDG_DATA_DIRS="$work/root/usr/share:/usr/share"
export XDG_RUNTIME_DIR="$work/runtime"
export QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software
unset QML_DISABLE_DISK_CACHE QML_FORCE_DISK_CACHE QML_DISK_CACHE_PATH QML_DISK_CACHE
launch_settings() {
if timeout 3s dbus-run-session -- "$work/root/usr/bin/plasma-panel-actions" --settings > "$work/launch.log" 2>&1; then
printf 'Settings exited before the test timeout.\n' >&2
cat "$work/launch.log" >&2
exit 1
else
status=$?
if (( status != 124 )); then
cat "$work/launch.log" >&2
exit "$status"
fi
fi
}
cache_for() {
for file in "$XDG_CACHE_HOME"/plasma-panel-actions/qmlcache/*.qmlc; do
[[ -f $file ]] || continue
if strings -el "$file" | grep -Fx "file://$1" > /dev/null; then
printf '%s\n' "$file"
return
fi
done
printf 'No QML cache was generated for %s\n' "$1" >&2
return 1
}
rpm2cpio "$old_rpm" | (cd "$work/root" && cpio -idm --quiet)
launch_settings
old_ui="$work/root/usr/share/plasma-panel-actions/ConfigGeneral.qml"
old_cache=$(cache_for "$old_ui")
old_checksum=$(sha256sum "$old_cache")
# Extract over the same location and keep the old cache intact. Changing
# the staging directory would hide the cache-collision regression.
rpm2cpio "$new_rpm" | (cd "$work/root" && cpio -idmu --quiet)
new_ui="$work/root$(rpm -qpl "$new_rpm" | grep '/ConfigGeneral.qml$')"
[[ $(stat -c %Y "$old_ui") == "$(stat -c %Y "$new_ui")" ]]
launch_settings
new_cache=$(cache_for "$new_ui")
[[ $new_cache != "$old_cache" ]]
[[ $(sha256sum "$old_cache") == "$old_checksum" ]]
strings -el "$new_cache" | grep -Fx twinFormLayouts > /dev/null
printf 'PASS: updated settings loaded with identical source timestamps and the old cache intact.\n'
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
#include "settings.h"
#include <QTemporaryDir>
#include <QTest>
class SettingsTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void savesWithoutAWidget()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
qputenv("XDG_CONFIG_HOME", directory.path().toUtf8());
Settings settings;
auto values = settings.values();
QCOMPARE(values.value(QStringLiteral("spacerLength")).toInt(), 48);
QCOMPARE(values.value(QStringLiteral("tasksAction")).toString(), QStringLiteral("volume"));
values[QStringLiteral("tasksAction")] = QStringLiteral("normal");
values[QStringLiteral("spacerLength")] = 128;
QVERIFY(settings.save(values));
Settings reopened;
QCOMPARE(reopened.values(), values);
auto invalid = values;
invalid[QStringLiteral("tasksAction")] = QStringLiteral("invalid");
QVERIFY(!settings.save(invalid));
QCOMPARE(settings.values(), values);
auto launch = values;
launch[QStringLiteral("leftDoubleClick")] = QStringLiteral("command");
QVERIFY(!settings.save(launch)); // empty command cannot be enabled
launch[QStringLiteral("leftDoubleClickCommand")] = QStringLiteral("true");
QVERIFY(settings.save(launch));
QCOMPARE(settings.values(), launch);
launch[QStringLiteral("middleDoubleClick")] = QStringLiteral("application");
QVERIFY(!settings.save(launch));
launch[QStringLiteral("middleDoubleClickApplication")] = QStringLiteral("org.kde.plasma-systemmonitor.desktop");
QVERIFY(settings.save(launch));
QCOMPARE(settings.values(), launch);
invalid = values;
invalid[QStringLiteral("spacerLength")] = -1;
QVERIFY(!settings.save(invalid));
QCOMPARE(settings.values(), launch);
}
};
QTEST_GUILESS_MAIN(SettingsTest)
#include "test-settings.moc"
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtTest
import "../controller/contents/ui/areas.js" as Areas
Item {
width: 600
height: 100
Item {
id: panel
x: 30
y: 20
width: 500
height: 40
Item {
width: 200
height: 40
property alias applet: tasks
Item {
id: tasks
anchors.fill: parent
property QtObject plasmoid: QtObject { property string pluginName: "org.kde.plasma.taskmanager" }
Item {
id: button
width: 80
height: 40
property Item tasksRoot: tasks
property bool isWindow: true
property int index: 0
}
}
}
Item {
x: 200
width: 60
height: 40
property QtObject applet: QtObject {
property QtObject plasmoid: QtObject { property string pluginName: "org.kde.plasma.systemtray" }
}
}
Item {
id: arbitraryWidget
x: 260
width: 60
height: 40
property QtObject applet: QtObject {
property QtObject plasmoid: QtObject { property string pluginName: "third.party.widget" }
}
}
}
TestCase {
name: "PanelAreas"
when: windowShown
function test_taskButtonsVersusUnusedSpace() {
compare(Areas.areaAt(panel, 40, 30), "tasks");
compare(Areas.areaAt(panel, 150, 30), "empty");
button.isWindow = false; // pinned launchers still count as buttons
compare(Areas.areaAt(panel, 40, 30), "tasks");
button.visible = false;
compare(Areas.areaAt(panel, 40, 30), "empty");
button.visible = true;
}
function test_trayAndUnrecognizedWidgetsKeepClicks() {
compare(Areas.areaAt(panel, 240, 30), "tray");
compare(Areas.areaAt(panel, 300, 30), "other");
arbitraryWidget.visible = false;
compare(Areas.areaAt(panel, 300, 30), "empty");
arbitraryWidget.visible = true;
compare(Areas.areaAt(null, 40, 30), "");
}
}
}
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtTest
TestCase {
name: "PanelConfiguration"
function test_savedValuesAndDefaults() {
const component = Qt.createComponent("../src/ConfigGeneral.qml");
compare(component.status, Component.Ready, component.errorString());
const config = component.createObject(this, {
width: 600, height: 800,
cfg_emptyAction: "brightness", cfg_tasksAction: "normal",
cfg_launcherAction: "volume", cfg_clockAction: "keyboard",
cfg_trayAction: "desktops", cfg_otherAction: "normal",
cfg_leftClick: "mute", cfg_middleClick: "overview",
cfg_leftDoubleClick: "command", cfg_leftDoubleClickCommand: "true",
cfg_middleDoubleClick: "application", cfg_middleDoubleClickApplication: "test.desktop",
applications: [{text: "Test application", value: "test.desktop"}],
cfg_spacerLength: 100
});
verify(config !== null);
compare(findChild(config, "emptyAction").currentValue, "brightness");
compare(findChild(config, "tasksAction").currentValue, "normal");
compare(findChild(config, "leftDoubleClick").currentValue, "command");
compare(findChild(config, "leftDoubleClickCommand").text, "true");
compare(findChild(config, "middleDoubleClickApplication").currentValue, "test.desktop");
config.cfg_emptyAction = "keyboard";
compare(findChild(config, "emptyAction").currentValue, "keyboard");
findChild(config, "restoreDefaults").clicked();
for (const area of ["empty", "tasks", "launcher", "clock", "tray", "other"]) {
compare(config["cfg_" + area + "Action"], "volume");
compare(findChild(config, area + "Action").currentValue, "volume");
}
compare(config.cfg_leftClick, "normal");
compare(config.cfg_middleClick, "normal");
compare(config.cfg_leftDoubleClick, "normal");
compare(config.cfg_middleDoubleClick, "normal");
compare(config.cfg_leftDoubleClickCommand, "");
compare(config.cfg_middleDoubleClickApplication, "");
compare(config.cfg_spacerLength, 48);
config.destroy();
component.destroy();
}
}
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtTest
import se.ajpanton.panelactions 1.0
TestCase {
name: "PanelPlugin"
PanelController { id: controller }
PanelSettings { id: panelSettings }
function test_loadsInstalledTypes() {
verify(controller !== null);
verify(panelSettings.values.emptyAction !== undefined);
}
function test_widgetComponentsCompile() {
for (const path of ["../package/contents/ui/main.qml", "../controller/contents/ui/main.qml"]) {
const component = Qt.createComponent(path);
compare(component.status, Component.Ready, component.errorString());
component.destroy();
}
}
function test_settingsWindowWithoutWidget() {
const component = Qt.createComponent("../src/SettingsWindow.qml");
compare(component.status, Component.Ready, component.errorString());
const window = component.createObject(null);
verify(window !== null);
tryCompare(window, "visible", true);
const form = findChild(window, "settingsForm");
verify(form !== null);
compare(form.cfg_tasksAction, panelSettings.values.tasksAction);
compare(findChild(form, "tasksAction").currentValue, panelSettings.values.tasksAction);
window.width = 600;
window.height = 400;
const save = findChild(window, "saveSettings");
const page = window.pageStack.currentItem;
tryVerify(() => page.flickable.contentHeight > page.flickable.height);
tryVerify(() => {
const position = save.mapToItem(window.contentItem, 0, 0);
return position.y >= 0 && position.y + save.height <= window.height;
});
const saveY = save.mapToItem(window.contentItem, 0, 0).y;
page.flickable.contentY = page.flickable.contentHeight - page.flickable.height;
compare(save.mapToItem(window.contentItem, 0, 0).y, saveY);
form.cfg_tasksAction = "brightness";
save.clicked();
compare(panelSettings.values.tasksAction, "brightness");
window.close();
window.destroy();
component.destroy();
}
function test_clickDetailsWrap() {
const component = Qt.createComponent("../src/SettingsWindow.qml");
compare(component.status, Component.Ready, component.errorString());
const window = component.createObject(null);
verify(window !== null);
const form = findChild(window, "settingsForm");
form.cfg_leftClick = "application";
form.applications = [{text: "A moderately long application name", value: "test.desktop"}];
form.cfg_leftClickApplication = "test.desktop";
form.cfg_middleClick = "command";
const action = findChild(form, "leftClick");
const application = findChild(form, "leftClickApplication");
const middleAction = findChild(form, "middleClick");
const command = findChild(form, "middleClickCommand");
const scrollAction = findChild(form, "emptyAction");
window.minimumWidth = 320;
for (const width of [760, 360, 900]) {
window.width = width;
if (width === 360) {
tryVerify(() => application.y >= action.y + action.height);
tryVerify(() => command.y >= middleAction.y + middleAction.height);
} else {
tryVerify(() => application.x >= action.x + action.width && application.y === action.y);
tryVerify(() => command.x >= middleAction.x + middleAction.width && command.y === middleAction.y);
}
verify(application.x + application.width <= application.parent.width);
verify(command.x + command.width <= command.parent.width);
tryCompare(application, "width", Math.min(application.implicitWidth, application.parent.width));
tryVerify(() => Math.abs(command.x + command.width - command.parent.width) < 1);
tryVerify(() => Math.abs(scrollAction.mapToItem(form, 0, 0).x - action.mapToItem(form, 0, 0).x) <= 1);
}
window.close();
window.destroy();
component.destroy();
}
}