From ccd01613981fbad549bff64f8701b337d83fe01d Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Fri, 11 Sep 2026 10:55:27 +0000 Subject: [PATCH] Match Plasma panel edge areas for clicks and scrolling --- plasma-panel-actions/README.md | 9 +- .../controller/contents/ui/areas.js | 68 +++++++-------- .../controller/contents/ui/main.qml | 7 +- .../plasma-panel-actions.spec | 4 +- plasma-panel-actions/src/panelcontroller.cpp | 8 +- plasma-panel-actions/src/panelcontroller.h | 2 +- .../tests/test-panelcontroller.cpp | 29 +++++-- plasma-panel-actions/tests/tst_areas.qml | 84 +++++++++++-------- 8 files changed, 117 insertions(+), 94 deletions(-) diff --git a/plasma-panel-actions/README.md b/plasma-panel-actions/README.md index ba836ba..57efb1a 100644 --- a/plasma-panel-actions/README.md +++ b/plasma-panel-actions/README.md @@ -15,10 +15,11 @@ Other choices are screen brightness, keyboard backlight, virtual desktops, and normal widget behaviour. Actions use Plasma's existing shortcuts, including its usual volume/brightness steps and on-screen indicators. -For scrolling, applet areas extend through the panel's thickness, and the first -and last visible items include the outer end padding. These areas follow the -actual layout, without fixed pixel offsets. Interior gaps, explicit spacers, -and unused Task Manager space remain empty. Click hit areas are unchanged. +Clicks and scrolling use Plasma's panel content boundaries and live theme +padding. Outer-margin input belongs to the same widget as a normal panel click, +including the launcher at a screen corner. Interior gaps, explicit spacers and +unused Task Manager space remain empty. Widget clicks pass through to Plasma; +no synthetic clicks or fixed area-expansion offsets are used. Left and middle clicks, and double clicks, on empty space can toggle mute, show the desktop, open Overview, play/pause media, launch an installed application, diff --git a/plasma-panel-actions/controller/contents/ui/areas.js b/plasma-panel-actions/controller/contents/ui/areas.js index b8ce4fd..a762a4f 100644 --- a/plasma-panel-actions/controller/contents/ui/areas.js +++ b/plasma-panel-actions/controller/contents/ui/areas.js @@ -2,28 +2,23 @@ .pragma library // Task Manager fills unused panel space. Only delegates are task buttons. -function hasTaskAt(item, x, y, vertical) { +function hasTaskAt(item, x, y) { if (!item.visible) return false; const point = item.mapFromItem(null, x, y); - if (vertical === undefined) { - if (!item.contains(point)) return false; - } else if (item.width <= 0 || item.height <= 0 - || (vertical ? point.y < 0 || point.y > item.height : point.x < 0 || point.x > item.width)) { - return false; - } + if (!item.contains(point)) return false; if (item.tasksRoot !== undefined && item.isWindow !== undefined && item.index !== undefined) return true; for (const child of item.children) { - if (hasTaskAt(child, x, y, vertical)) return true; + if (hasTaskAt(child, x, y)) return true; } return false; } -function appletArea(child, x, y, vertical) { +function appletArea(child, x, y) { const applet = child.applet; const name = applet?.plasmoid?.pluginName; if (!name) return "other"; if (name === "org.kde.plasma.taskmanager" || name === "org.kde.plasma.icontasks") { - return hasTaskAt(applet, x, y, vertical) ? "tasks" : "empty"; + return hasTaskAt(applet, x, y) ? "tasks" : "empty"; } if (name === "se.ajpanton.panelactions" || name === "org.kde.plasma.panelspacer" || name === "org.kde.plasma.marginsseparator") return "empty"; @@ -34,8 +29,31 @@ function appletArea(child, x, y, vertical) { return "other"; } -function areaAt(panelLayout, x, y) { - if (!panelLayout) return ""; +function panelViewFor(item) { + for (let parent = item.parent; parent; parent = parent.parent) { + if (parent.containment && parent.leftPadding !== undefined + && parent.rightPadding !== undefined && parent.topPadding !== undefined + && parent.bottomPadding !== undefined) return parent; + } + return null; +} + +function areaAt(panelLayout, x, y, panelView) { + if (!panelLayout || !panelView?.containment) return ""; + // Match Plasma PanelView's containmentContainsPosition / positionAdjustedForContainment. + // Read the same live theme padding and containment origin, including floating offsets. + const containment = panelView.containment; + const origin = containment.mapToItem(null, 0, 0); + const left = origin.x + panelView.leftPadding; + const top = origin.y + panelView.topPadding; + const right = origin.x + containment.width - panelView.rightPadding; + const bottom = origin.y + containment.height - panelView.bottomPadding; + if (right <= left || bottom <= top) return ""; + if (x < left || x >= right || y < top || y >= bottom) { + // The trailing boundary itself is outside Qt Quick's input area. + x = Math.max(left, Math.min(x, right - 1)); + y = Math.max(top, Math.min(y, bottom - 1)); + } for (const child of panelLayout.children) { if (child.visible && child.contains(child.mapFromItem(null, x, y))) { return appletArea(child, x, y); @@ -43,29 +61,3 @@ function areaAt(panelLayout, x, y) { } return "empty"; } - -// Scrolling owns the full panel thickness and its outer end padding. Interior -// gaps and explicit spacers remain empty; clicks keep their exact hit testing. -function scrollAreaAt(panelLayout, x, y, vertical) { - if (!panelLayout) return ""; - const point = panelLayout.mapFromItem(null, x, y); - const along = vertical ? point.y : point.x; - let first = null; - let last = null; - for (const child of panelLayout.children) { - if (!child.visible || child.width <= 0 || child.height <= 0) continue; - const a = child.mapToItem(panelLayout, 0, 0); - const b = child.mapToItem(panelLayout, child.width, child.height); - const start = vertical ? Math.min(a.y, b.y) : Math.min(a.x, b.x); - const end = vertical ? Math.max(a.y, b.y) : Math.max(a.x, b.x); - if (end <= start) continue; - if (along >= start && along < end) return appletArea(child, x, y, vertical); - if (!first || start < first.start) first = {child, start}; - if (!last || end > last.end) last = {child, end}; - } - const edge = first && along < first.start ? first : last && along >= last.end ? last : null; - if (!edge) return "empty"; - const boundary = edge === first ? first.start : last.end; - const projected = panelLayout.mapToItem(null, vertical ? point.x : boundary, vertical ? boundary : point.y); - return appletArea(edge.child, projected.x, projected.y, vertical); -} diff --git a/plasma-panel-actions/controller/contents/ui/main.qml b/plasma-panel-actions/controller/contents/ui/main.qml index 8851076..10f256a 100644 --- a/plasma-panel-actions/controller/contents/ui/main.qml +++ b/plasma-panel-actions/controller/contents/ui/main.qml @@ -8,6 +8,7 @@ import "areas.js" as Areas PlasmoidItem { id: root + readonly property Item panelView: Areas.panelViewFor(root) readonly property Item panelLayout: { let item = root.parent; while (item) { @@ -26,10 +27,8 @@ PlasmoidItem { preferredRepresentation: fullRepresentation PanelSettings { id: settings } PanelController { - active: root.panelLayout !== null && !(Plasmoid.containment.corona?.editMode ?? false) - locateArea: (x, y, scrolling) => scrolling - ? Areas.scrollAreaAt(root.panelLayout, x, y, Plasmoid.formFactor === PlasmaCore.Types.Vertical) - : Areas.areaAt(root.panelLayout, x, y) + active: root.panelLayout !== null && root.panelView !== null && !(Plasmoid.containment.corona?.editMode ?? false) + locateArea: (x, y) => Areas.areaAt(root.panelLayout, x, y, root.panelView) scrollActions: ({ empty: settings.values.emptyAction, tasks: settings.values.tasksAction, diff --git a/plasma-panel-actions/plasma-panel-actions.spec b/plasma-panel-actions/plasma-panel-actions.spec index a488bc1..1487c45 100644 --- a/plasma-panel-actions/plasma-panel-actions.spec +++ b/plasma-panel-actions/plasma-panel-actions.spec @@ -1,6 +1,6 @@ Name: plasma-panel-actions Version: 0.1.0 -Release: 9%{?dist} +Release: 10%{?dist} Summary: Configurable panel scrolling and empty-space click actions License: MIT URL: https://git.ajpanton.se/ajp_anton/fedora-tools @@ -47,6 +47,8 @@ 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 +* 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 - Assign panel scroll margins using applet geometry without changing clicks * Mon Sep 07 2026 fedora-tools contributors - 0.1.0-8 diff --git a/plasma-panel-actions/src/panelcontroller.cpp b/plasma-panel-actions/src/panelcontroller.cpp index 888083f..169edd1 100644 --- a/plasma-panel-actions/src/panelcontroller.cpp +++ b/plasma-panel-actions/src/panelcontroller.cpp @@ -50,12 +50,14 @@ PanelController::~PanelController() } } -QString PanelController::areaAt(const QPointF &position, bool scrolling) +QString PanelController::areaAt(const QPointF &position) { + // Dragging outside the panel must not project back onto one of its widgets. + if (!m_panel || !QRectF(0, 0, m_panel->width(), m_panel->height()).contains(position)) return {}; if (!m_locateArea.isCallable()) { return {}; } - const QJSValue result = m_locateArea.call({position.x(), position.y(), scrolling}); + const QJSValue result = m_locateArea.call({position.x(), position.y()}); if (result.isError()) { qWarning() << "Panel Actions: could not identify panel area:" << result.toString(); return {}; @@ -74,7 +76,7 @@ bool PanelController::eventFilter(QObject *object, QEvent *event) } if (event->type() == QEvent::Wheel) { auto *wheel = static_cast(event); - const QString area = areaAt(wheel->position(), true); + const QString area = areaAt(wheel->position()); const QString action = m_scrollActions.value(area, QStringLiteral("normal")).toString(); if (action == QLatin1String("normal") || action.isEmpty()) { m_remainder = 0; diff --git a/plasma-panel-actions/src/panelcontroller.h b/plasma-panel-actions/src/panelcontroller.h index d6e8001..9c86239 100644 --- a/plasma-panel-actions/src/panelcontroller.h +++ b/plasma-panel-actions/src/panelcontroller.h @@ -27,7 +27,7 @@ Q_SIGNALS: protected: bool eventFilter(QObject *object, QEvent *event) override; private: - QString areaAt(const QPointF &position, bool scrolling = false); + QString areaAt(const QPointF &position); QPointer m_panel; bool m_active = false; QJSValue m_locateArea; diff --git a/plasma-panel-actions/tests/test-panelcontroller.cpp b/plasma-panel-actions/tests/test-panelcontroller.cpp index 824f314..abd015f 100644 --- a/plasma-panel-actions/tests/test-panelcontroller.cpp +++ b/plasma-panel-actions/tests/test-panelcontroller.cpp @@ -17,7 +17,7 @@ public: bool event(QEvent *event) override { if (event->type() == QEvent::Wheel || event->type() == QEvent::MouseButtonPress - || event->type() == QEvent::MouseButtonRelease || event->type() == QEvent::MouseMove) { + || event->type() == QEvent::MouseButtonRelease || event->type() == QEvent::MouseButtonDblClick || event->type() == QEvent::MouseMove) { ++delivered; return true; } @@ -31,6 +31,7 @@ class PanelControllerTest : public QObject private: static void setup(PanelController &controller, PanelWindow &window, QJSEngine &engine) { + window.resize(400, 40); controller.setParentItem(window.contentItem()); controller.setProperty("active", true); controller.setProperty("locateArea", QVariant::fromValue(engine.evaluate( @@ -52,15 +53,15 @@ private: QCoreApplication::sendEvent(&window, &event); } private Q_SLOTS: - void scrollHitTestingDoesNotExpandClickTargets() + void clicksAndScrollsShareWidgetAreas() { QJSEngine engine; PanelWindow window; PanelController controller; setup(controller, window, engine); - controller.setLocateArea(engine.evaluate(u"(function(x,y,scrolling) { return scrolling ? 'launcher' : 'empty'; })"_s)); + controller.setLocateArea(engine.evaluate(u"(function(x,y) { return 'launcher'; })"_s)); controller.setProperty("scrollActions", QVariantMap{{u"launcher"_s, u"brightness"_s}, {u"empty"_s, u"volume"_s}}); - controller.setProperty("clickActions", QVariantMap{{u"leftClick"_s, u"mute"_s}}); + controller.setProperty("clickActions", QVariantMap{{u"leftClick"_s, u"mute"_s}, {u"leftDoubleClick"_s, u"overview"_s}}); QSignalSpy scrolling(&controller, &PanelController::actionRequested); QSignalSpy clicking(&controller, &PanelController::clickRequested); wheel(window, 120); @@ -68,8 +69,11 @@ private Q_SLOTS: QCOMPARE(scrolling.first(), QVariantList({u"brightness"_s, 1})); mouse(window, QEvent::MouseButtonPress, 20); mouse(window, QEvent::MouseButtonRelease, 20); - QCOMPARE(clicking.count(), 1); - QCOMPARE(clicking.first(), QVariantList({u"leftClick"_s})); + mouse(window, QEvent::MouseButtonPress, 20); + mouse(window, QEvent::MouseButtonDblClick, 20); + mouse(window, QEvent::MouseButtonRelease, 20); + QCOMPARE(clicking.count(), 0); + QCOMPARE(window.delivered, 5); // Plasma handles both clicks, including margin forwarding. } void wheelOverridesAndNormalBehaviour() { @@ -145,11 +149,24 @@ private Q_SLOTS: mouse(window, QEvent::MouseButtonRelease, 20); QCOMPARE(actions.count(), 0); } + void releaseOutsidePanelDoesNotClick() + { + 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, 1); + mouse(window, QEvent::MouseButtonRelease, -1); + QCOMPARE(actions.count(), 0); + } void disabledAndOtherWindowsAreUntouched() { QJSEngine engine; PanelWindow window; PanelWindow popup; + popup.resize(400, 40); PanelController controller; setup(controller, window, engine); QSignalSpy actions(&controller, &PanelController::actionRequested); diff --git a/plasma-panel-actions/tests/tst_areas.qml b/plasma-panel-actions/tests/tst_areas.qml index 9eb9471..ef00f4b 100644 --- a/plasma-panel-actions/tests/tst_areas.qml +++ b/plasma-panel-actions/tests/tst_areas.qml @@ -4,6 +4,12 @@ import QtTest import "../controller/contents/ui/areas.js" as Areas Item { + id: scene + property Item containment: edges + property int leftPadding: edges.vertical ? 8 : 12 + property int rightPadding: edges.vertical ? 8 : 14 + property int topPadding: edges.vertical ? 12 : 8 + property int bottomPadding: edges.vertical ? 14 : 8 width: 600 height: 100 Item { @@ -94,68 +100,72 @@ Item { function init() { edges.vertical = false; edges.reversed = false; - edges.scale = 1; + edges.x = 40; launcher.visible = true; } - function scrollAt(along, across) { + function areaAt(along, across) { const p = edges.mapToItem(null, edges.vertical ? across : along, edges.vertical ? along : across); - return Areas.scrollAreaAt(edges, p.x, p.y, edges.vertical); + return Areas.areaAt(edges, p.x, p.y, scene); } - function test_scrollMargins_data() { + function test_panelMargins_data() { return [ - {tag: "horizontal", vertical: false, scale: 1}, - {tag: "vertical", vertical: true, scale: 1}, - {tag: "scaled", vertical: false, scale: 1.5}, - {tag: "vertical-scaled", vertical: true, scale: 1.5} + {tag: "horizontal", vertical: false, x: 40}, + {tag: "vertical", vertical: true, x: 40}, + {tag: "fractional-origin", vertical: false, x: 40.5}, + {tag: "vertical-fractional-origin", vertical: true, x: 40.5} ]; } - function test_scrollMargins(data) { + function test_panelMargins(data) { edges.vertical = data.vertical; - edges.scale = data.scale; - compare(scrollAt(-20, 58), "launcher"); // leading outer corner - compare(scrollAt(30, 2), "launcher"); // thickness padding above/beside icon - compare(scrollAt(420, 58), "clock"); // trailing outer corner - compare(scrollAt(65, 58), "empty"); // internal gap - compare(scrollAt(120, 58), "empty"); // explicit spacer - compare(scrollAt(200, 58), "empty"); + edges.x = data.x; + compare(areaAt(-20, 58), "launcher"); // leading outer corner + compare(areaAt(30, 2), "launcher"); // thickness padding above/beside icon + compare(areaAt(420, 58), "clock"); // trailing outer corner + compare(areaAt(386, 52), "clock"); // exclusive bottom/right boundaries + compare(areaAt(385.75, 51.75), "clock"); // fractional positions just inside + compare(areaAt(65, 58), "empty"); // internal gap + compare(areaAt(120, 58), "empty"); // explicit spacer + compare(areaAt(200, 58), "empty"); edges.reversed = true; // physical order, not QObject child order - compare(scrollAt(-20, 58), "clock"); - compare(scrollAt(420, 58), "launcher"); + compare(areaAt(-20, 58), "clock"); + compare(areaAt(420, 58), "launcher"); } - function test_clickMarginsStayEmpty() { - const p = edges.mapToItem(null, -20, 58); - compare(Areas.areaAt(edges, p.x, p.y), "empty"); - compare(Areas.scrollAreaAt(edges, p.x, p.y, false), "launcher"); + function test_panelViewDiscovery() { + compare(Areas.panelViewFor(launcher), scene); const inside = launcher.mapToItem(null, 10, 10); - compare(Areas.areaAt(edges, inside.x, inside.y), "launcher"); + compare(Areas.areaAt(edges, inside.x, inside.y, scene), "launcher"); + compare(Areas.areaAt(edges, inside.x, inside.y, null), ""); } function test_hiddenAndZeroSizeDoNotOwnPadding() { launcher.visible = false; - compare(scrollAt(-20, 58), "empty"); // spacer becomes first - compare(Areas.scrollAreaAt(null, 0, 0, false), ""); + compare(areaAt(-20, 58), "empty"); // don't stretch the next widget into a real gap + compare(Areas.areaAt(null, 0, 0, scene), ""); + } + function taskArea(x, y) { + return Areas.areaAt(panel, x, y, {containment: panel, + leftPadding: 0, rightPadding: 0, topPadding: 0, bottomPadding: 0}); } function test_taskThicknessPaddingKeepsUnusedSpaceEmpty() { - compare(Areas.scrollAreaAt(panel, 40, 65, false), "tasks"); - compare(Areas.scrollAreaAt(panel, 150, 65, false), "empty"); - compare(Areas.areaAt(panel, 40, 65), "empty"); - compare(Areas.scrollAreaAt(panel, 0, 65, false), "tasks"); + compare(taskArea(40, 65), "tasks"); + compare(taskArea(150, 65), "empty"); + compare(taskArea(0, 65), "tasks"); } function test_taskButtonsVersusUnusedSpace() { - compare(Areas.areaAt(panel, 40, 30), "tasks"); - compare(Areas.areaAt(panel, 150, 30), "empty"); + compare(taskArea(40, 30), "tasks"); + compare(taskArea(150, 30), "empty"); button.isWindow = false; // pinned launchers still count as buttons - compare(Areas.areaAt(panel, 40, 30), "tasks"); + compare(taskArea(40, 30), "tasks"); button.visible = false; - compare(Areas.areaAt(panel, 40, 30), "empty"); + compare(taskArea(40, 30), "empty"); button.visible = true; } function test_trayAndUnrecognizedWidgetsKeepClicks() { - compare(Areas.areaAt(panel, 240, 30), "tray"); - compare(Areas.areaAt(panel, 300, 30), "other"); + compare(taskArea(240, 30), "tray"); + compare(taskArea(300, 30), "other"); arbitraryWidget.visible = false; - compare(Areas.areaAt(panel, 300, 30), "empty"); + compare(taskArea(300, 30), "empty"); arbitraryWidget.visible = true; - compare(Areas.areaAt(null, 40, 30), ""); + compare(Areas.areaAt(null, 40, 30, scene), ""); } } }