From 787231cb03f9a2eb020208fe623e7214d6481d45 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Wed, 9 Sep 2026 22:12:24 +0000 Subject: [PATCH] Use applet geometry for panel scroll margins --- plasma-panel-actions/README.md | 5 ++ .../controller/contents/ui/areas.js | 70 +++++++++++---- .../controller/contents/ui/main.qml | 4 +- .../plasma-panel-actions.spec | 4 +- plasma-panel-actions/src/panelcontroller.cpp | 6 +- plasma-panel-actions/src/panelcontroller.h | 2 +- .../tests/test-panelcontroller.cpp | 19 ++++ plasma-panel-actions/tests/tst_areas.qml | 89 +++++++++++++++++++ 8 files changed, 177 insertions(+), 22 deletions(-) diff --git a/plasma-panel-actions/README.md b/plasma-panel-actions/README.md index 760e5f1..ba836ba 100644 --- a/plasma-panel-actions/README.md +++ b/plasma-panel-actions/README.md @@ -15,6 +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. + 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, or run a custom shell command as the current user. All default to normal diff --git a/plasma-panel-actions/controller/contents/ui/areas.js b/plasma-panel-actions/controller/contents/ui/areas.js index ebd9cf8..b8ce4fd 100644 --- a/plasma-panel-actions/controller/contents/ui/areas.js +++ b/plasma-panel-actions/controller/contents/ui/areas.js @@ -2,32 +2,70 @@ .pragma library // Task Manager fills unused panel space. Only delegates are task buttons. -function hasTaskAt(item, x, y) { - if (!item.visible || !item.contains(item.mapFromItem(null, x, y))) return false; +function hasTaskAt(item, x, y, vertical) { + 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.tasksRoot !== undefined && item.isWindow !== undefined && item.index !== undefined) return true; for (const child of item.children) { - if (hasTaskAt(child, x, y)) return true; + if (hasTaskAt(child, x, y, vertical)) return true; } return false; } +function appletArea(child, x, y, vertical) { + 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"; + } + if (name === "se.ajpanton.panelactions" || name === "org.kde.plasma.panelspacer" + || name === "org.kde.plasma.marginsseparator") return "empty"; + if (name === "org.kde.plasma.systemtray") return "tray"; + if (name === "org.kde.plasma.digitalclock" || name === "org.kde.plasma.analogclock") return "clock"; + if (name === "org.kde.plasma.kickoff" || name === "org.kde.plasma.kicker" + || name === "org.kde.plasma.kickerdash") return "launcher"; + return "other"; +} + function areaAt(panelLayout, x, y) { if (!panelLayout) return ""; for (const child of panelLayout.children) { - if (!child.visible || !child.contains(child.mapFromItem(null, x, y))) continue; - 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) ? "tasks" : "empty"; + if (child.visible && child.contains(child.mapFromItem(null, x, y))) { + return appletArea(child, x, y); } - if (name === "se.ajpanton.panelactions" || name === "org.kde.plasma.panelspacer" - || name === "org.kde.plasma.marginsseparator") return "empty"; - if (name === "org.kde.plasma.systemtray") return "tray"; - if (name === "org.kde.plasma.digitalclock" || name === "org.kde.plasma.analogclock") return "clock"; - if (name === "org.kde.plasma.kickoff" || name === "org.kde.plasma.kicker" - || name === "org.kde.plasma.kickerdash") return "launcher"; - return "other"; } 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 4288b53..8851076 100644 --- a/plasma-panel-actions/controller/contents/ui/main.qml +++ b/plasma-panel-actions/controller/contents/ui/main.qml @@ -27,7 +27,9 @@ PlasmoidItem { PanelSettings { id: settings } PanelController { active: root.panelLayout !== null && !(Plasmoid.containment.corona?.editMode ?? false) - locateArea: (x, y) => Areas.areaAt(root.panelLayout, x, y) + locateArea: (x, y, scrolling) => scrolling + ? Areas.scrollAreaAt(root.panelLayout, x, y, Plasmoid.formFactor === PlasmaCore.Types.Vertical) + : Areas.areaAt(root.panelLayout, x, y) 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 4bb9080..a488bc1 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: 8%{?dist} +Release: 9%{?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 +* 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 - Invalidate settings caches on UI changes with content-specific install paths * Mon Sep 07 2026 fedora-tools contributors - 0.1.0-7 diff --git a/plasma-panel-actions/src/panelcontroller.cpp b/plasma-panel-actions/src/panelcontroller.cpp index 1b0552e..888083f 100644 --- a/plasma-panel-actions/src/panelcontroller.cpp +++ b/plasma-panel-actions/src/panelcontroller.cpp @@ -50,12 +50,12 @@ PanelController::~PanelController() } } -QString PanelController::areaAt(const QPointF &position) +QString PanelController::areaAt(const QPointF &position, bool scrolling) { if (!m_locateArea.isCallable()) { return {}; } - const QJSValue result = m_locateArea.call({position.x(), position.y()}); + const QJSValue result = m_locateArea.call({position.x(), position.y(), scrolling}); if (result.isError()) { qWarning() << "Panel Actions: could not identify panel area:" << result.toString(); return {}; @@ -74,7 +74,7 @@ bool PanelController::eventFilter(QObject *object, QEvent *event) } if (event->type() == QEvent::Wheel) { auto *wheel = static_cast(event); - const QString area = areaAt(wheel->position()); + const QString area = areaAt(wheel->position(), true); 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 9c86239..d6e8001 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); + QString areaAt(const QPointF &position, bool scrolling = false); 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 f74cc52..824f314 100644 --- a/plasma-panel-actions/tests/test-panelcontroller.cpp +++ b/plasma-panel-actions/tests/test-panelcontroller.cpp @@ -52,6 +52,25 @@ private: QCoreApplication::sendEvent(&window, &event); } private Q_SLOTS: + void scrollHitTestingDoesNotExpandClickTargets() + { + 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.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}}); + QSignalSpy scrolling(&controller, &PanelController::actionRequested); + QSignalSpy clicking(&controller, &PanelController::clickRequested); + wheel(window, 120); + QCOMPARE(scrolling.count(), 1); + 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})); + } void wheelOverridesAndNormalBehaviour() { QJSEngine engine; diff --git a/plasma-panel-actions/tests/tst_areas.qml b/plasma-panel-actions/tests/tst_areas.qml index aa35390..9eb9471 100644 --- a/plasma-panel-actions/tests/tst_areas.qml +++ b/plasma-panel-actions/tests/tst_areas.qml @@ -48,9 +48,98 @@ Item { } } } + Item { + id: edges + x: 40 + y: 10 + property bool vertical: false + property bool reversed: false + width: vertical ? 60 : 400 + height: vertical ? 400 : 60 + Item { width: 0; height: 60 } // invisible controller's zero-width slot + Item { x: -100; y: -100; width: 50; height: 50; visible: false } + Item { + id: launcher + x: edges.vertical ? 8 : edges.reversed ? 350 : 12 + y: edges.vertical ? (edges.reversed ? 350 : 12) : 8 + width: edges.vertical ? 44 : 36 + height: edges.vertical ? 36 : 44 + property QtObject applet: QtObject { + property QtObject plasmoid: QtObject { property string pluginName: "org.kde.plasma.kickoff" } + } + } + Item { + x: edges.vertical ? 8 : 90 + y: edges.vertical ? 90 : 8 + width: edges.vertical ? 44 : 80 + height: edges.vertical ? 80 : 44 + property QtObject applet: QtObject { + property QtObject plasmoid: QtObject { property string pluginName: "org.kde.plasma.panelspacer" } + } + } + Item { + id: clock + x: edges.vertical ? 8 : edges.reversed ? 12 : 350 + y: edges.vertical ? (edges.reversed ? 12 : 350) : 8 + width: edges.vertical ? 44 : 36 + height: edges.vertical ? 36 : 44 + property QtObject applet: QtObject { + property QtObject plasmoid: QtObject { property string pluginName: "org.kde.plasma.digitalclock" } + } + } + } TestCase { name: "PanelAreas" when: windowShown + function init() { + edges.vertical = false; + edges.reversed = false; + edges.scale = 1; + launcher.visible = true; + } + function scrollAt(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); + } + function test_scrollMargins_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} + ]; + } + function test_scrollMargins(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.reversed = true; // physical order, not QObject child order + compare(scrollAt(-20, 58), "clock"); + compare(scrollAt(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"); + const inside = launcher.mapToItem(null, 10, 10); + compare(Areas.areaAt(edges, inside.x, inside.y), "launcher"); + } + function test_hiddenAndZeroSizeDoNotOwnPadding() { + launcher.visible = false; + compare(scrollAt(-20, 58), "empty"); // spacer becomes first + compare(Areas.scrollAreaAt(null, 0, 0, false), ""); + } + 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"); + } function test_taskButtonsVersusUnusedSpace() { compare(Areas.areaAt(panel, 40, 30), "tasks"); compare(Areas.areaAt(panel, 150, 30), "empty");