Use applet geometry for panel scroll margins

This commit is contained in:
ajp_anton
2026-09-09 22:12:24 +00:00
parent f5988605c8
commit 787231cb03
8 changed files with 177 additions and 22 deletions
+5
View File
@@ -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
@@ -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);
}
@@ -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,
@@ -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
+3 -3
View File
@@ -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<QWheelEvent *>(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;
+1 -1
View File
@@ -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<QQuickWindow> m_panel;
bool m_active = false;
QJSValue m_locateArea;
@@ -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;
+89
View File
@@ -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");