Match Plasma panel edge areas for clicks and scrolling

This commit is contained in:
ajp_anton
2026-09-11 10:55:27 +00:00
parent 8719402d97
commit ccd0161398
8 changed files with 117 additions and 94 deletions
+5 -4
View File
@@ -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,
@@ -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.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);
}
@@ -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,
@@ -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
+5 -3
View File
@@ -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<QWheelEvent *>(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;
+1 -1
View File
@@ -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<QQuickWindow> m_panel;
bool m_active = false;
QJSValue m_locateArea;
@@ -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);
+47 -37
View File
@@ -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), "");
}
}
}