From 027ca22908a6f020c101ddd7e8f512fe0186655d Mon Sep 17 00:00:00 2001
From: Marco Martin <notmart@gmail.com>
Date: Tue, 2 Aug 2022 15:59:26 +0000
Subject: [PATCH] When an arrow key is not accepted look for adjacent views

When no qml items manage the arrow key event, the root item will manage it looking to give focus to a view in the given direction derived from the arrow key

BUG:455783
---
 src/effects/desktopgrid/qml/main.qml          | 34 +++++++
 src/effects/overview/qml/ScreenView.qml       | 26 +++++
 .../private/qml/WindowHeapDelegate.qml        |  2 +-
 src/effects/windowview/qml/main.qml           | 24 +++++
 src/libkwineffects/kwinquickeffect.cpp        | 96 ++++++++++++++++---
 src/libkwineffects/kwinquickeffect.h          | 12 ++-
 src/scripting/CMakeLists.txt                  |  1 +
 src/scripting/scripting.cpp                   |  2 +
 8 files changed, 182 insertions(+), 15 deletions(-)

diff --git a/src/effects/desktopgrid/qml/main.qml b/src/effects/desktopgrid/qml/main.qml
index bca7a782f9..e9f2afd613 100644
--- a/src/effects/desktopgrid/qml/main.qml
+++ b/src/effects/desktopgrid/qml/main.qml
@@ -6,6 +6,7 @@
 */
 
 import QtQuick 2.12
+import QtQuick.Window 2.12
 import QtQuick.Layouts 1.12
 import QtGraphicalEffects 1.12
 import org.kde.kwin 3.0 as KWinComponents
@@ -100,12 +101,36 @@ Rectangle {
             switchTo(desktopId);
         } else if (event.key == Qt.Key_Up) {
             event.accepted = selectNext(WindowHeap.Direction.Up);
+            if (!event.accepted) {
+                let view = effect.getView(Qt.TopEdge)
+                if (view) {
+                    effect.activateView(view)
+                }
+            }
         } else if (event.key == Qt.Key_Down) {
             event.accepted = selectNext(WindowHeap.Direction.Down);
+            if (!event.accepted) {
+                let view = effect.getView(Qt.BottomEdge)
+                if (view) {
+                    effect.activateView(view)
+                }
+            }
         } else if (event.key == Qt.Key_Left) {
             event.accepted = selectNext(WindowHeap.Direction.Left);
+            if (!event.accepted) {
+                let view = effect.getView(Qt.LeftEdge)
+                if (view) {
+                    effect.activateView(view)
+                }
+            }
         } else if (event.key == Qt.Key_Right) {
             event.accepted = selectNext(WindowHeap.Direction.Right);
+            if (!event.accepted) {
+                let view = effect.getView(Qt.RightEdge)
+                if (view) {
+                    effect.activateView(view)
+                }
+            }
         } else if (event.key == Qt.Key_Return || event.key == Qt.Key_Space) {
             for (let i = 0; i < gridRepeater.count; i++) {
                 if (gridRepeater.itemAt(i).focus) {
@@ -229,6 +254,15 @@ Rectangle {
                 height: container.height
 
                 clientModel: stackModel
+                Rectangle {
+                    anchors.fill: parent
+                    color: "transparent"
+                    border {
+                        color: PlasmaCore.Theme.highlightColor
+                        width: 1 / grid.scale
+                    }
+                    visible: parent.activeFocus
+                }
                 TapHandler {
                     acceptedButtons: Qt.LeftButton
                     onTapped: {
diff --git a/src/effects/overview/qml/ScreenView.qml b/src/effects/overview/qml/ScreenView.qml
index 15044f8b75..56a0f9d19d 100644
--- a/src/effects/overview/qml/ScreenView.qml
+++ b/src/effects/overview/qml/ScreenView.qml
@@ -42,6 +42,7 @@ FocusScope {
 
     Keys.priority: Keys.AfterItem
     Keys.forwardTo: searchField
+
     Keys.onEnterPressed: {
         heap.forceActiveFocus();
         if (heap.count === 1) {
@@ -49,6 +50,31 @@ FocusScope {
         }
     }
 
+    Keys.onLeftPressed: {
+        let view = effect.getView(Qt.LeftEdge)
+        if (view) {
+            effect.activateView(view)
+        }
+    }
+    Keys.onRightPressed: {
+        let view = effect.getView(Qt.RightEdge)
+        if (view) {
+            effect.activateView(view)
+        }
+    }
+    Keys.onUpPressed: {
+        let view = effect.getView(Qt.TopEdge)
+        if (view) {
+            effect.activateView(view)
+        }
+    }
+    Keys.onDownPressed: {
+        let view = effect.getView(Qt.BottomEdge)
+        if (view) {
+            effect.activateView(view)
+        }
+    }
+
     KWinComponents.DesktopBackgroundItem {
         id: backgroundItem
         activity: KWinComponents.Workspace.currentActivity
diff --git a/src/effects/private/qml/WindowHeapDelegate.qml b/src/effects/private/qml/WindowHeapDelegate.qml
index 31efcaeac5..cbcb970886 100644
--- a/src/effects/private/qml/WindowHeapDelegate.qml
+++ b/src/effects/private/qml/WindowHeapDelegate.qml
@@ -290,7 +290,7 @@ Item {
         imagePath: "widgets/viewitem"
         prefix: "hover"
         z: -1
-        visible: !thumb.windowHeap.dragActive && (hoverHandler.hovered || selected)
+        visible: !thumb.windowHeap.dragActive && (hoverHandler.hovered || selected) && Window.window.activeFocusItem
     }
 
     HoverHandler {
diff --git a/src/effects/windowview/qml/main.qml b/src/effects/windowview/qml/main.qml
index 5b926f0b5a..b7ed45ec04 100644
--- a/src/effects/windowview/qml/main.qml
+++ b/src/effects/windowview/qml/main.qml
@@ -38,6 +38,30 @@ Item {
 
     Keys.priority: Keys.AfterItem
     Keys.forwardTo: searchField
+    Keys.onLeftPressed: {
+        let view = effect.getView(Qt.LeftEdge)
+        if (view) {
+            effect.activateView(view)
+        }
+    }
+    Keys.onRightPressed: {
+        let view = effect.getView(Qt.RightEdge)
+        if (view) {
+            effect.activateView(view)
+        }
+    }
+    Keys.onUpPressed: {
+        let view = effect.getView(Qt.TopEdge)
+        if (view) {
+            effect.activateView(view)
+        }
+    }
+    Keys.onDownPressed: {
+        let view = effect.getView(Qt.BottomEdge)
+        if (view) {
+            effect.activateView(view)
+        }
+    }
 
     KWinComponents.DesktopBackgroundItem {
         activity: KWinComponents.Workspace.currentActivity
diff --git a/src/libkwineffects/kwinquickeffect.cpp b/src/libkwineffects/kwinquickeffect.cpp
index 99bf35f852..990509da11 100644
--- a/src/libkwineffects/kwinquickeffect.cpp
+++ b/src/libkwineffects/kwinquickeffect.cpp
@@ -220,17 +220,89 @@ QuickSceneView *QuickSceneEffect::viewAt(const QPoint &pos) const
     return nullptr;
 }
 
+QuickSceneView *QuickSceneEffect::activeView() const
+{
+    auto it = std::find_if(d->views.constBegin(), d->views.constEnd(), [](QuickSceneView *v) {
+        return v->window()->activeFocusItem();
+    });
+
+    QuickSceneView *screenView = nullptr;
+
+    if (it == d->views.constEnd()) {
+        screenView = d->views.value(effects->activeScreen());
+    } else {
+        screenView = (*it);
+    }
+
+    return screenView;
+}
+
+KWin::QuickSceneView *QuickSceneEffect::getView(Qt::Edge edge)
+{
+    auto screenView = activeView();
+
+    QuickSceneView *candidate = nullptr;
+
+    for (auto *v : d->views) {
+        switch (edge) {
+        case Qt::LeftEdge:
+            if (v->geometry().left() < screenView->geometry().left()) {
+                // Look for the nearest view from the current
+                if (!candidate || v->geometry().left() > candidate->geometry().left() || (v->geometry().left() == candidate->geometry().left() && v->geometry().top() > candidate->geometry().top())) {
+                    candidate = v;
+                }
+            }
+            break;
+        case Qt::TopEdge:
+            if (v->geometry().top() < screenView->geometry().top()) {
+                if (!candidate || v->geometry().top() > candidate->geometry().top() || (v->geometry().top() == candidate->geometry().top() && v->geometry().left() > candidate->geometry().left())) {
+                    candidate = v;
+                }
+            }
+            break;
+        case Qt::RightEdge:
+            if (v->geometry().right() > screenView->geometry().right()) {
+                if (!candidate || v->geometry().right() < candidate->geometry().right() || (v->geometry().right() == candidate->geometry().right() && v->geometry().top() > candidate->geometry().top())) {
+                    candidate = v;
+                }
+            }
+            break;
+        case Qt::BottomEdge:
+            if (v->geometry().bottom() > screenView->geometry().bottom()) {
+                if (!candidate || v->geometry().bottom() < candidate->geometry().bottom() || (v->geometry().bottom() == candidate->geometry().bottom() && v->geometry().left() > candidate->geometry().left())) {
+                    candidate = v;
+                }
+            }
+            break;
+        }
+    }
+
+    return candidate;
+}
+
 void QuickSceneEffect::activateView(QuickSceneView *view)
 {
+    if (!view) {
+        return;
+    }
+
+    auto *av = activeView();
+    // Already properly active?
+    if (view == av && av->window()->activeFocusItem()) {
+        return;
+    }
+
     for (auto *otherView : d->views) {
         if (otherView == view && !view->window()->activeFocusItem()) {
             QFocusEvent focusEvent(QEvent::FocusIn, Qt::ActiveWindowFocusReason);
             qApp->sendEvent(view->window(), &focusEvent);
-        } else if (otherView->window()->activeFocusItem()) {
+        } else if (otherView != view && otherView->window()->activeFocusItem()) {
             QFocusEvent focusEvent(QEvent::FocusOut, Qt::ActiveWindowFocusReason);
             qApp->sendEvent(otherView->window(), &focusEvent);
         }
     }
+
+    Q_EMIT activeViewChanged(view);
 }
 
 // Screen views are repainted just before kwin performs its compositing cycle to avoid stalling for vblank
@@ -362,6 +434,9 @@ void QuickSceneEffect::startInternal()
         addScreen(screen);
     }
 
+    // Ensure one view has an active focus item
+    activateView(activeView());
+
     connect(effects, &EffectsHandler::screenAdded, this, &QuickSceneEffect::handleScreenAdded);
     connect(effects, &EffectsHandler::screenRemoved, this, &QuickSceneEffect::handleScreenRemoved);
 
@@ -424,19 +499,12 @@ void QuickSceneEffect::windowInputMouseEvent(QEvent *event)
 
 void QuickSceneEffect::grabbedKeyboardEvent(QKeyEvent *keyEvent)
 {
-    auto it = std::find_if(d->views.constBegin(), d->views.constEnd(), [](QuickSceneView *v) {
-        return v->window()->activeFocusItem();
-    });
+    auto *screenView = activeView();
 
-    if (it == d->views.constEnd()) {
-        QuickSceneView *screenView = d->views.value(effects->activeScreen());
-        if (screenView) {
-            activateView(screenView);
-            screenView->forwardKeyEvent(keyEvent);
-        }
-    } else {
-        (*it)->forwardKeyEvent(keyEvent);
-        return;
+    if (screenView) {
+        // ActiveView may not have an activeFocusItem yet
+        activateView(screenView);
+        screenView->forwardKeyEvent(keyEvent);
     }
 }
 
@@ -472,3 +540,5 @@ bool QuickSceneEffect::touchUp(qint32 id, quint32 time)
 }
 
 } // namespace KWin
+
+#include <moc_kwinquickeffect.cpp>
diff --git a/src/libkwineffects/kwinquickeffect.h b/src/libkwineffects/kwinquickeffect.h
index 7e2de551ab..f5b07cf633 100644
--- a/src/libkwineffects/kwinquickeffect.h
+++ b/src/libkwineffects/kwinquickeffect.h
@@ -66,6 +66,7 @@ private:
 class KWINEFFECTS_EXPORT QuickSceneEffect : public Effect
 {
     Q_OBJECT
+    Q_PROPERTY(QuickSceneView *activeView READ activeView NOTIFY activeViewChanged)
 
 public:
     explicit QuickSceneEffect(QObject *parent = nullptr);
@@ -81,6 +82,8 @@ public:
      */
     void setRunning(bool running);
 
+    QuickSceneView *activeView() const;
+
     /**
      * Returns all scene views managed by this effect. If the effect is not running,
      * this function returns an empty QHash.
@@ -92,10 +95,16 @@ public:
      */
     QuickSceneView *viewAt(const QPoint &pos) const;
 
+    /**
+     * Get a view at the given direction from the active view
+     * Returns null if no other views exist in the given direction 
+     */
+    Q_INVOKABLE KWin::QuickSceneView *getView(Qt::Edge edge);
+
     /**
      * Sets the given @a view as active. It will get a focusin event and all the other views will be set as inactive
      */
-    void activateView(QuickSceneView *view);
+    Q_INVOKABLE void activateView(QuickSceneView *view);
 
     /**
      * Returns the source URL.
@@ -134,6 +143,7 @@ public:
 Q_SIGNALS:
     void itemDraggedOutOfScreen(QQuickItem *item, QList<EffectScreen *> screens);
     void itemDroppedOutOfScreen(const QPointF &globalPos, QQuickItem *item, EffectScreen *screen);
+    void activeViewChanged(KWin::QuickSceneView *view);
 
 protected:
     /**
diff --git a/src/scripting/CMakeLists.txt b/src/scripting/CMakeLists.txt
index 88ba5b644d..4c0ee71da4 100644
--- a/src/scripting/CMakeLists.txt
+++ b/src/scripting/CMakeLists.txt
@@ -10,6 +10,7 @@ if (KWIN_BUILD_KCMS)
         KF5::Service
         Qt::DBus
         Qt::UiTools
+        kwineffects
     )
     install(TARGETS kcm_kwin4_genericscripted DESTINATION ${KDE_INSTALL_PLUGINDIR}/kwin/effects/configs)
 endif()
diff --git a/src/scripting/scripting.cpp b/src/scripting/scripting.cpp
index a990c9aac4..200cbf9000 100644
--- a/src/scripting/scripting.cpp
+++ b/src/scripting/scripting.cpp
@@ -13,6 +13,7 @@
 // own
 #include "dbuscall.h"
 #include "desktopbackgrounditem.h"
+#include "kwinquickeffect.h"
 #include "screenedgeitem.h"
 #include "scripting_logging.h"
 #include "scriptingutils.h"
@@ -658,6 +659,7 @@ void KWin::Scripting::init()
     qmlRegisterType<ScriptingModels::V3::ClientModel>("org.kde.kwin", 3, 0, "ClientModel");
     qmlRegisterType<ScriptingModels::V3::ClientFilterModel>("org.kde.kwin", 3, 0, "ClientFilterModel");
     qmlRegisterType<ScriptingModels::V3::VirtualDesktopModel>("org.kde.kwin", 3, 0, "VirtualDesktopModel");
+    qmlRegisterUncreatableType<KWin::QuickSceneView>("org.kde.kwin", 3, 0, "SceneView", QStringLiteral("Can't instantiate an object of type SceneView"));
 
     qmlRegisterSingletonType<DeclarativeScriptWorkspaceWrapper>("org.kde.kwin", 3, 0, "Workspace", [](QQmlEngine *qmlEngine, QJSEngine *jsEngine) {
         Q_UNUSED(qmlEngine)