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 normal widget behaviour. Actions use Plasma's existing shortcuts, including its
usual volume/brightness steps and on-screen indicators. 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 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, 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 or run a custom shell command as the current user. All default to normal
@@ -2,24 +2,28 @@
.pragma library .pragma library
// Task Manager fills unused panel space. Only delegates are task buttons. // Task Manager fills unused panel space. Only delegates are task buttons.
function hasTaskAt(item, x, y) { function hasTaskAt(item, x, y, vertical) {
if (!item.visible || !item.contains(item.mapFromItem(null, x, y))) return false; 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; if (item.tasksRoot !== undefined && item.isWindow !== undefined && item.index !== undefined) return true;
for (const child of item.children) { for (const child of item.children) {
if (hasTaskAt(child, x, y)) return true; if (hasTaskAt(child, x, y, vertical)) return true;
} }
return false; return false;
} }
function areaAt(panelLayout, x, y) { function appletArea(child, x, y, vertical) {
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 applet = child.applet;
const name = applet?.plasmoid?.pluginName; const name = applet?.plasmoid?.pluginName;
if (!name) return "other"; if (!name) return "other";
if (name === "org.kde.plasma.taskmanager" || name === "org.kde.plasma.icontasks") { if (name === "org.kde.plasma.taskmanager" || name === "org.kde.plasma.icontasks") {
return hasTaskAt(applet, x, y) ? "tasks" : "empty"; return hasTaskAt(applet, x, y, vertical) ? "tasks" : "empty";
} }
if (name === "se.ajpanton.panelactions" || name === "org.kde.plasma.panelspacer" if (name === "se.ajpanton.panelactions" || name === "org.kde.plasma.panelspacer"
|| name === "org.kde.plasma.marginsseparator") return "empty"; || name === "org.kde.plasma.marginsseparator") return "empty";
@@ -28,6 +32,40 @@ function areaAt(panelLayout, x, y) {
if (name === "org.kde.plasma.kickoff" || name === "org.kde.plasma.kicker" if (name === "org.kde.plasma.kickoff" || name === "org.kde.plasma.kicker"
|| name === "org.kde.plasma.kickerdash") return "launcher"; || name === "org.kde.plasma.kickerdash") return "launcher";
return "other"; 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))) {
return appletArea(child, x, y);
}
} }
return "empty"; 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 } PanelSettings { id: settings }
PanelController { PanelController {
active: root.panelLayout !== null && !(Plasmoid.containment.corona?.editMode ?? false) 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: ({ scrollActions: ({
empty: settings.values.emptyAction, empty: settings.values.emptyAction,
tasks: settings.values.tasksAction, tasks: settings.values.tasksAction,
@@ -1,6 +1,6 @@
Name: plasma-panel-actions Name: plasma-panel-actions
Version: 0.1.0 Version: 0.1.0
Release: 8%{?dist} Release: 9%{?dist}
Summary: Configurable panel scrolling and empty-space click actions Summary: Configurable panel scrolling and empty-space click actions
License: MIT License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools 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 %{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-panel-actions-autostart.desktop
%{_datadir}/fedora-tools/settings/plasma-panel-actions.json %{_datadir}/fedora-tools/settings/plasma-panel-actions.json
%changelog %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 * Mon Sep 07 2026 fedora-tools contributors - 0.1.0-8
- Invalidate settings caches on UI changes with content-specific install paths - Invalidate settings caches on UI changes with content-specific install paths
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-7 * 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()) { if (!m_locateArea.isCallable()) {
return {}; 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()) { if (result.isError()) {
qWarning() << "Panel Actions: could not identify panel area:" << result.toString(); qWarning() << "Panel Actions: could not identify panel area:" << result.toString();
return {}; return {};
@@ -74,7 +74,7 @@ bool PanelController::eventFilter(QObject *object, QEvent *event)
} }
if (event->type() == QEvent::Wheel) { if (event->type() == QEvent::Wheel) {
auto *wheel = static_cast<QWheelEvent *>(event); 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(); const QString action = m_scrollActions.value(area, QStringLiteral("normal")).toString();
if (action == QLatin1String("normal") || action.isEmpty()) { if (action == QLatin1String("normal") || action.isEmpty()) {
m_remainder = 0; m_remainder = 0;
+1 -1
View File
@@ -27,7 +27,7 @@ Q_SIGNALS:
protected: protected:
bool eventFilter(QObject *object, QEvent *event) override; bool eventFilter(QObject *object, QEvent *event) override;
private: private:
QString areaAt(const QPointF &position); QString areaAt(const QPointF &position, bool scrolling = false);
QPointer<QQuickWindow> m_panel; QPointer<QQuickWindow> m_panel;
bool m_active = false; bool m_active = false;
QJSValue m_locateArea; QJSValue m_locateArea;
@@ -52,6 +52,25 @@ private:
QCoreApplication::sendEvent(&window, &event); QCoreApplication::sendEvent(&window, &event);
} }
private Q_SLOTS: 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() void wheelOverridesAndNormalBehaviour()
{ {
QJSEngine engine; 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 { TestCase {
name: "PanelAreas" name: "PanelAreas"
when: windowShown 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() { function test_taskButtonsVersusUnusedSpace() {
compare(Areas.areaAt(panel, 40, 30), "tasks"); compare(Areas.areaAt(panel, 40, 30), "tasks");
compare(Areas.areaAt(panel, 150, 30), "empty"); compare(Areas.areaAt(panel, 150, 30), "empty");