Convergence: stabilize workspace scroll gestures

Improve touchpad scroll handling for convergence workflows while avoiding gesture conflicts.

- make SwipeArea handle angleDelta-based touchpad wheel streams and phase-less sequences safely
- wire topbar ActionDrawerOpenSurface to virtual desktop info and horizontal workspace switching logic
- add dock pager wheel switching in FavouritesBar for the vertical wheel axis environments deliver
- prevent wallpaper/home surface touchpad scroll from opening app drawer in convergence
- extend convergence invariant checks for the new gesture paths
This commit is contained in:
Marco Allegretti 2026-05-25 21:59:19 +02:00
parent 30e3006e3f
commit 1a70b24c06
7 changed files with 253 additions and 20 deletions

View file

@ -10,9 +10,12 @@
#include <QObject>
#include <QTabletEvent>
#include <QTouchEvent>
#include <QWheelEvent>
// how many pixels to move before it starts being registered as a swipe
const int SWIPE_REGISTER_THRESHOLD = 10;
const qreal WHEEL_STEP_PIXEL_DELTA = 48.0;
const int TOUCHPAD_SCROLL_END_TIMEOUT = 160;
SwipeArea::SwipeArea(QQuickItem *parent)
: QQuickItem{parent}
@ -20,6 +23,10 @@ SwipeArea::SwipeArea(QQuickItem *parent)
setAcceptTouchEvents(true);
setAcceptedMouseButtons(Qt::LeftButton);
setFiltersChildMouseEvents(true);
m_touchpadScrollEndTimer.setSingleShot(true);
m_touchpadScrollEndTimer.setInterval(TOUCHPAD_SCROLL_END_TIMEOUT);
connect(&m_touchpadScrollEndTimer, &QTimer::timeout, this, &SwipeArea::endTouchpadScroll);
}
SwipeArea::Mode SwipeArea::mode() const
@ -225,43 +232,71 @@ void SwipeArea::wheelEvent(QWheelEvent *event)
event->setAccepted(false);
const bool isTouchpad = event->deviceType() == QInputDevice::DeviceType::TouchPad;
QPointF scrollDelta = event->pixelDelta();
if (scrollDelta.isNull() && !event->angleDelta().isNull()) {
scrollDelta = QPointF(event->angleDelta()) / QWheelEvent::DefaultDeltasPerStep * WHEEL_STEP_PIXEL_DELTA;
}
switch (event->phase()) {
case Qt::ScrollBegin:
if (!m_touchpadScrolling) {
if (isTouchpad && !m_touchpadScrolling) {
event->accept();
m_touchpadScrolling = true;
m_totalScrollDelta = QPointF{0, 0};
Q_EMIT touchpadScrollStarted(event->points().first().position());
startTouchpadScroll(event->points().first().position());
}
break;
case Qt::ScrollEnd:
if (m_touchpadScrolling) {
m_touchpadScrolling = false;
m_totalScrollDelta = QPointF{0, 0};
Q_EMIT touchpadScrollEnded();
endTouchpadScroll();
event->accept();
}
break;
return;
default:
break;
}
// HACK: if it isn't the touchpad, we never get the isBeginEvent() and isEndEvent() events
if (!m_touchpadScrolling) {
return;
if (!isTouchpad || scrollDelta.isNull()) {
return;
}
startTouchpadScroll(event->points().first().position());
}
for (auto &point : event->points()) {
event->addPassiveGrabber(point, this);
}
auto pixelDelta = event->pixelDelta();
m_totalScrollDelta = QPointF{m_totalScrollDelta + pixelDelta};
Q_EMIT touchpadScrollMove(m_totalScrollDelta.x(), m_totalScrollDelta.y(), pixelDelta.x(), pixelDelta.y());
m_totalScrollDelta = QPointF{m_totalScrollDelta + scrollDelta};
Q_EMIT touchpadScrollMove(m_totalScrollDelta.x(), m_totalScrollDelta.y(), scrollDelta.x(), scrollDelta.y());
m_touchpadScrollEndTimer.start();
event->accept();
}
void SwipeArea::startTouchpadScroll(QPointF point)
{
if (m_touchpadScrolling) {
return;
}
m_touchpadScrolling = true;
m_totalScrollDelta = QPointF{0, 0};
Q_EMIT touchpadScrollStarted(point);
}
void SwipeArea::endTouchpadScroll()
{
if (!m_touchpadScrolling) {
return;
}
m_touchpadScrollEndTimer.stop();
m_touchpadScrolling = false;
m_totalScrollDelta = QPointF{0, 0};
Q_EMIT touchpadScrollEnded();
}
void SwipeArea::setMoving(bool moving)
{
m_moving = moving;

View file

@ -8,6 +8,7 @@
#include <QPointerEvent>
#include <QQmlListProperty>
#include <QQuickItem>
#include <QTimer>
#include <QTouchEvent>
/**
@ -84,6 +85,8 @@ private:
void handlePressEvent(QPointerEvent *event, QPointF point);
void handleReleaseEvent(QPointerEvent *event, QPointF point);
void handleMoveEvent(QPointerEvent *event, QPointF point);
void startTouchpadScroll(QPointF point);
void endTouchpadScroll();
Mode m_mode = Mode::BothAxis;
bool m_interactive = true;
@ -110,6 +113,8 @@ private:
// the total amount of distance scrolled
QPointF m_totalScrollDelta;
QTimer m_touchpadScrollEndTimer;
};
QML_DECLARE_TYPE(SwipeArea)

View file

@ -17,6 +17,15 @@ MobileShell.SwipeArea {
mode: MobileShell.SwipeArea.VerticalOnly
required property ActionDrawer actionDrawer
property var virtualDesktopInfo: null
readonly property real touchpadDirectionLockThreshold: 12
readonly property real touchpadWorkspaceSwitchThreshold: 80
readonly property real touchpadAxisDominance: 1.25
property point touchpadStartPoint: Qt.point(0, 0)
property string touchpadGestureMode: ""
property bool touchpadWorkspaceSwitched: false
function startSwipe() {
if (actionDrawer.intendedToBeVisible) {
@ -54,12 +63,96 @@ MobileShell.SwipeArea {
actionDrawer.offset += offsetY;
}
function workspaceScrollAvailable() {
return ShellSettings.Settings.convergenceModeEnabled
&& virtualDesktopInfo !== null
&& virtualDesktopInfo.numberOfDesktops > 1;
}
function desktopIndexForId(desktopId) {
if (virtualDesktopInfo === null || !virtualDesktopInfo.desktopIds) {
return -1;
}
for (let i = 0; i < virtualDesktopInfo.desktopIds.length; ++i) {
if (String(virtualDesktopInfo.desktopIds[i]) === String(desktopId)) {
return i;
}
}
return -1;
}
function activateAdjacentWorkspace(direction) {
if (!workspaceScrollAvailable()) {
return;
}
const currentIndex = desktopIndexForId(virtualDesktopInfo.currentDesktop);
if (currentIndex < 0) {
return;
}
const targetIndex = Math.max(0, Math.min(virtualDesktopInfo.desktopIds.length - 1, currentIndex + direction));
if (targetIndex !== currentIndex) {
virtualDesktopInfo.requestActivate(virtualDesktopInfo.desktopIds[targetIndex]);
}
}
function startTouchpadScroll(point) {
touchpadStartPoint = point;
touchpadGestureMode = "";
touchpadWorkspaceSwitched = false;
if (!ShellSettings.Settings.convergenceModeEnabled) {
touchpadGestureMode = "drawer";
startSwipeWithPoint(point);
}
}
function moveTouchpadScroll(totalDeltaX, totalDeltaY, deltaX, deltaY) {
if (touchpadGestureMode === "") {
const absX = Math.abs(totalDeltaX);
const absY = Math.abs(totalDeltaY);
if (absY >= touchpadDirectionLockThreshold && absY > absX * touchpadAxisDominance) {
touchpadGestureMode = "drawer";
startSwipeWithPoint(touchpadStartPoint);
updateOffset(totalDeltaY);
return;
} else if (workspaceScrollAvailable() && absX >= touchpadDirectionLockThreshold && absX > absY * touchpadAxisDominance) {
touchpadGestureMode = "workspace";
} else {
return;
}
}
if (touchpadGestureMode === "drawer") {
updateOffset(deltaY);
return;
}
if (!touchpadWorkspaceSwitched && Math.abs(totalDeltaX) >= touchpadWorkspaceSwitchThreshold) {
touchpadWorkspaceSwitched = true;
activateAdjacentWorkspace(totalDeltaX < 0 ? 1 : -1);
}
}
function endTouchpadScroll() {
if (touchpadGestureMode === "drawer") {
endSwipe();
}
touchpadGestureMode = "";
touchpadWorkspaceSwitched = false;
}
anchors.fill: parent
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
scrollGestureEnabled: false
cursorShape: ShellSettings.Settings.convergenceModeEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
}
@ -67,9 +160,9 @@ MobileShell.SwipeArea {
onSwipeEnded: endSwipe()
onSwipeMove: (totalDeltaX, totalDeltaY, deltaX, deltaY) => updateOffset(deltaY);
onTouchpadScrollStarted: (point) => startSwipeWithPoint(point)
onTouchpadScrollEnded: endSwipe()
onTouchpadScrollMove: (totalDeltaX, totalDeltaY, deltaX, deltaY) => updateOffset(deltaY);
onTouchpadScrollStarted: (point) => startTouchpadScroll(point)
onTouchpadScrollEnded: endTouchpadScroll()
onTouchpadScrollMove: (totalDeltaX, totalDeltaY, deltaX, deltaY) => moveTouchpadScroll(totalDeltaX, totalDeltaY, deltaX, deltaY);
// In convergence mode, allow click to toggle the action drawer (mouse-friendly)
onClicked: {

View file

@ -94,6 +94,8 @@ MouseArea {
// Virtual desktop pager (convergence mode, 2+ desktops)
readonly property bool showPager: convergenceMode && virtualDesktopInfo.numberOfDesktops > 1
property real pagerButtonWidth: showPager ? Math.min(root.height, Kirigami.Units.gridUnit * 2.5) : 0
property int pagerWheelDelta: 0
property bool pagerWheelLocked: false
readonly property int pagerLeftCount: showPager ? Math.ceil(virtualDesktopInfo.numberOfDesktops / 2) : 0
readonly property int pagerRightCount: showPager ? virtualDesktopInfo.numberOfDesktops - pagerLeftCount : 0
property real desktopButtonWidth: convergenceMode ? root.height : 0
@ -128,6 +130,15 @@ MouseArea {
onTriggered: root.hideDockToolTip(root.activeDockToolTipItem)
}
Timer {
id: pagerWheelEndTimer
interval: 160
onTriggered: {
root.pagerWheelDelta = 0
root.pagerWheelLocked = false
}
}
function requestDockToolTip(item) {
activeDockToolTipItem = null
pendingDockToolTipItem = item
@ -192,6 +203,50 @@ MouseArea {
return -1
}
function activateAdjacentDesktop(direction) {
let ids = virtualDesktopInfo.desktopIds
if (!ids || ids.length <= 1) {
return
}
let currentIndex = root.desktopIndexForId(virtualDesktopInfo.currentDesktop)
if (currentIndex < 0) {
return
}
let targetIndex = Math.max(0, Math.min(ids.length - 1, currentIndex + direction))
if (targetIndex !== currentIndex) {
root.folio.activateVirtualDesktop(String(ids[targetIndex]))
}
}
function handlePagerWheel(wheel) {
if (!root.showPager) {
return
}
const axisDelta = wheel.angleDelta.y || -wheel.angleDelta.x
if (axisDelta === 0) {
return
}
pagerWheelEndTimer.restart()
if (root.pagerWheelLocked) {
return
}
root.pagerWheelDelta += axisDelta * (wheel.inverted ? -1 : 1)
if (root.pagerWheelDelta >= 120) {
root.pagerWheelLocked = true
root.pagerWheelDelta = 0
root.activateAdjacentDesktop(-1)
} else if (root.pagerWheelDelta <= -120) {
root.pagerWheelLocked = true
root.pagerWheelDelta = 0
root.activateAdjacentDesktop(1)
}
}
function dynamicTilingMoveToDesktopAction(desktopId) {
let index = root.desktopIndexForId(desktopId)
if (index < 0) {
@ -685,6 +740,7 @@ MouseArea {
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onWheel: (wheel) => root.handlePagerWheel(wheel)
onClicked: (mouse) => {
root.hideDockToolTip(leftDesktopBtn)
if (mouse.button === Qt.RightButton) {
@ -794,6 +850,7 @@ MouseArea {
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onWheel: (wheel) => root.handlePagerWheel(wheel)
onClicked: (mouse) => {
root.hideDockToolTip(rightDesktopBtn)
if (mouse.button === Qt.RightButton) {

View file

@ -188,9 +188,21 @@ Item {
homeScreenState.swipeMoved(totalDeltaX, totalDeltaY, deltaX, deltaY);
}
onTouchpadScrollStarted: homeScreenState.swipeStarted(0, 0);
onTouchpadScrollEnded: homeScreenState.swipeEnded();
onTouchpadScrollMove: (totalDeltaX, totalDeltaY, deltaX, deltaY) => homeScreenState.swipeMoved(totalDeltaX, totalDeltaY, deltaX, deltaY);
onTouchpadScrollStarted: {
if (!ShellSettings.Settings.convergenceModeEnabled) {
root.homeScreenState.swipeStarted(0, 0);
}
}
onTouchpadScrollEnded: {
if (!ShellSettings.Settings.convergenceModeEnabled) {
root.homeScreenState.swipeEnded();
}
}
onTouchpadScrollMove: (totalDeltaX, totalDeltaY, deltaX, deltaY) => {
if (!ShellSettings.Settings.convergenceModeEnabled) {
root.homeScreenState.swipeMoved(totalDeltaX, totalDeltaY, deltaX, deltaY);
}
}
onPressedChanged: {
if (pressed) {

View file

@ -51,6 +51,10 @@ Item {
value: drawer.visible
}
TaskManager.VirtualDesktopInfo {
id: virtualDesktopInfo
}
//END API implementation
// Startup feedback fill animation
@ -155,6 +159,7 @@ Item {
MobileShell.ActionDrawerOpenSurface {
id: swipeArea
actionDrawer: drawer.actionDrawer
virtualDesktopInfo: virtualDesktopInfo
anchors.fill: parent
readonly property alias drawerVisible: drawer.visible

View file

@ -14,7 +14,10 @@ taskpanel="$repo_root/containments/taskpanel/qml/main.qml"
folio_main="$repo_root/containments/homescreens/folio/qml/main.qml"
shellutil_header="$repo_root/components/mobileshell/shellutil.h"
shellutil_cpp="$repo_root/components/mobileshell/shellutil.cpp"
swipearea_header="$repo_root/components/mobileshell/components/swipearea.h"
swipearea_cpp="$repo_root/components/mobileshell/components/swipearea.cpp"
folio_home="$repo_root/containments/homescreens/folio/qml/FolioHomeScreen.qml"
favourites_bar="$repo_root/containments/homescreens/folio/qml/FavouritesBar.qml"
folio_backend="$repo_root/containments/homescreens/folio/homescreen.h"
folio_backend_cpp="$repo_root/containments/homescreens/folio/homescreen.cpp"
action_content="$repo_root/components/mobileshell/qml/actiondrawer/private/ContentContainer.qml"
@ -60,11 +63,34 @@ require_line "$panel" "visible: !ShellSettings.Settings.gamingModeEnabled"
require_line "$status_panel" "&& !ShellSettings.Settings.convergenceModeEnabled"
require_line "$status_panel" "visible: !ShellSettings.Settings.convergenceModeEnabled"
require_line "$status_panel" "TaskManager.VirtualDesktopInfo {"
require_line "$status_panel" "MobileShell.ActionDrawerOpenSurface {"
require_line "$status_panel" "virtualDesktopInfo: virtualDesktopInfo"
require_line "$action_open_surface" "property var virtualDesktopInfo: null"
require_line "$action_open_surface" "touchpadWorkspaceSwitchThreshold"
require_line "$action_open_surface" "function activateAdjacentWorkspace(direction)"
require_line "$action_open_surface" "virtualDesktopInfo.requestActivate(virtualDesktopInfo.desktopIds[targetIndex]);"
require_line "$action_open_surface" "absY >= touchpadDirectionLockThreshold && absY > absX * touchpadAxisDominance"
require_line "$action_open_surface" "workspaceScrollAvailable() && absX >= touchpadDirectionLockThreshold && absX > absY * touchpadAxisDominance"
require_line "$action_open_surface" "MouseArea {"
require_line "$action_open_surface" "acceptedButtons: Qt.NoButton"
require_line "$action_open_surface" "hoverEnabled: true"
require_line "$action_open_surface" "scrollGestureEnabled: false"
require_line "$action_open_surface" "cursorShape: ShellSettings.Settings.convergenceModeEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor"
require_line "$swipearea_cpp" "const qreal WHEEL_STEP_PIXEL_DELTA = 48.0;"
require_line "$swipearea_cpp" "const int TOUCHPAD_SCROLL_END_TIMEOUT = 160;"
require_line "$swipearea_cpp" "const bool isTouchpad = event->deviceType() == QInputDevice::DeviceType::TouchPad;"
require_line "$swipearea_cpp" "startTouchpadScroll(event->points().first().position());"
require_line "$swipearea_cpp" "m_touchpadScrollEndTimer.start();"
require_line "$swipearea_cpp" "if (scrollDelta.isNull() && !event->angleDelta().isNull())"
require_line "$swipearea_cpp" "QWheelEvent::DefaultDeltasPerStep * WHEEL_STEP_PIXEL_DELTA"
require_line "$swipearea_header" "QTimer m_touchpadScrollEndTimer;"
require_line "$favourites_bar" "function handlePagerWheel(wheel)"
require_line "$favourites_bar" "onWheel: (wheel) => root.handlePagerWheel(wheel)"
require_line "$favourites_bar" "root.activateAdjacentDesktop(1)"
require_line "$folio_home" "onTouchpadScrollStarted: {"
require_line "$folio_home" "onTouchpadScrollMove: (totalDeltaX, totalDeltaY, deltaX, deltaY) => {"
require_line "$folio_home" "if (!ShellSettings.Settings.convergenceModeEnabled) {"
require_line "$status_bar_template" "panel.location = \"top\";"