gestures: Add optional gesture guide panel

This adds a gesture handle mode to the navigation panel, which can be
enabled during gesture-only mode. This reserves space for the system
gesture to be able to be used, allowing us to extend the height in KWin
of the gesture recognition area (which is currently far too short for devices such as Pixel 3a).

This also allows for navigation with a mouse; clicking on the handle
triggers the task switcher, holding it triggers the "home" action.
This commit is contained in:
Devin Lin 2025-12-13 23:38:13 -05:00
parent be90b3f74b
commit 28f7224a5b
9 changed files with 248 additions and 62 deletions

View file

@ -67,6 +67,7 @@ ecm_target_qml_sources(mobileshellplugin SOURCES
qml/homescreen/HomeScreen.qml qml/homescreen/HomeScreen.qml
qml/homescreen/WallpaperSelector.qml qml/homescreen/WallpaperSelector.qml
qml/navigationpanel/GesturePanel.qml
qml/navigationpanel/NavigationPanel.qml qml/navigationpanel/NavigationPanel.qml
qml/navigationpanel/NavigationPanelAction.qml qml/navigationpanel/NavigationPanelAction.qml
qml/navigationpanel/NavigationPanelButton.qml qml/navigationpanel/NavigationPanelButton.qml

View file

@ -28,11 +28,13 @@ QtObject {
} }
readonly property real defaultNavigationPanelThickness: Kirigami.Units.gridUnit * 2 readonly property real defaultNavigationPanelThickness: Kirigami.Units.gridUnit * 2
readonly property real defaultGesturePanelThickness: Kirigami.Units.gridUnit
readonly property real navigationPanelThickness: { readonly property real navigationPanelThickness: {
if (!ShellSettings.Settings.navigationPanelEnabled) { if (!ShellSettings.Settings.navigationPanelEnabled) {
return 0; return ShellSettings.Settings.gesturePanelEnabled ? defaultGesturePanelThickness : 0;
} else if (root.panelSettings.navigationPanelHeight <= 0) { }
if (root.panelSettings.navigationPanelHeight <= 0) {
return defaultNavigationPanelThickness; return defaultNavigationPanelThickness;
} }
return root.panelSettings.navigationPanelHeight; return root.panelSettings.navigationPanelHeight;

View file

@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2025 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Layouts
import QtQuick.Window
import org.kde.plasma.plasmoid
import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.kirigami as Kirigami
Rectangle {
id: root
// Whether the bar background should be opaque
required property bool opaqueBar
signal handleClicked()
signal handlePressedAndHeld()
color: opaqueBar ? Kirigami.Theme.backgroundColor : "transparent"
// Handle
MouseArea {
anchors.centerIn: parent
width: Math.min(root.width * 0.2, Kirigami.Units.gridUnit * 12)
height: parent.height
cursorShape: Qt.PointingHandCursor
onClicked: root.handleClicked()
onPressAndHold: root.handlePressedAndHeld()
Rectangle {
anchors.centerIn: parent
width: parent.width
height: 4
radius: height / 2
opacity: 0.8
color: Kirigami.Theme.textColor
}
}
}

View file

@ -36,6 +36,7 @@ MobileShellSettings::MobileShellSettings(QObject *parent)
Q_EMIT statusBarScaleFactorChanged(); Q_EMIT statusBarScaleFactorChanged();
Q_EMIT showBatteryPercentageChanged(); Q_EMIT showBatteryPercentageChanged();
Q_EMIT navigationPanelEnabledChanged(); Q_EMIT navigationPanelEnabledChanged();
Q_EMIT gesturePanelEnabledChanged();
Q_EMIT alwaysShowKeyboardToggleOnNavigationPanelChanged(); Q_EMIT alwaysShowKeyboardToggleOnNavigationPanelChanged();
Q_EMIT keyboardButtonEnabledChanged(); Q_EMIT keyboardButtonEnabledChanged();
Q_EMIT taskSwitcherPreviewsEnabledChanged(); Q_EMIT taskSwitcherPreviewsEnabledChanged();
@ -145,7 +146,22 @@ void MobileShellSettings::setNavigationPanelEnabled(bool navigationPanelEnabled)
group.writeEntry("navigationPanelEnabled", navigationPanelEnabled, KConfigGroup::Notify); group.writeEntry("navigationPanelEnabled", navigationPanelEnabled, KConfigGroup::Notify);
m_config->sync(); m_config->sync();
updateNavigationBarsInPlasma(navigationPanelEnabled); updateNavigationBarsInPlasma();
}
bool MobileShellSettings::gesturePanelEnabled() const
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
return group.readEntry("gesturePanelEnabled", true);
}
void MobileShellSettings::setGesturePanelEnabled(bool gesturePanelEnabled)
{
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};
group.writeEntry("gesturePanelEnabled", gesturePanelEnabled, KConfigGroup::Notify);
m_config->sync();
updateNavigationBarsInPlasma();
} }
bool MobileShellSettings::alwaysShowKeyboardToggleOnNavigationPanel() const bool MobileShellSettings::alwaysShowKeyboardToggleOnNavigationPanel() const
@ -232,7 +248,7 @@ void MobileShellSettings::setAutoHidePanelsEnabled(bool enabled)
m_config->sync(); m_config->sync();
} }
void MobileShellSettings::updateNavigationBarsInPlasma(bool navigationPanelEnabled) void MobileShellSettings::updateNavigationBarsInPlasma()
{ {
// Do not update panels when not in Plasma Mobile // Do not update panels when not in Plasma Mobile
bool isMobilePlatform = KRuntimePlatform::runtimePlatform().contains("phone"); bool isMobilePlatform = KRuntimePlatform::runtimePlatform().contains("phone");
@ -245,13 +261,22 @@ void MobileShellSettings::updateNavigationBarsInPlasma(bool navigationPanelEnabl
QLatin1String("org.kde.PlasmaShell"), QLatin1String("org.kde.PlasmaShell"),
QLatin1String("evaluateScript")); QLatin1String("evaluateScript"));
if (navigationPanelEnabled) { if (navigationPanelEnabled() || gesturePanelEnabled()) {
QString createNavigationPanelScript = R"( QString createNavigationPanelScript = R"(
loadTemplate("org.kde.plasma.mobile.defaultNavigationPanel"); let allPanels = panels();
let foundPanel = false;
for (var i = 0; i < allPanels.length; i++) {
if (allPanels[i].type === "org.kde.plasma.mobile.taskpanel") {
foundPanel = true;
}
}
if (!foundPanel) {
loadTemplate("org.kde.plasma.mobile.defaultNavigationPanel");
}
)"; )";
message << createNavigationPanelScript; message << createNavigationPanelScript;
} else { } else {
QString deleteNavigationPanelScript = R"( QString deleteNavigationPanelScript = R"(
let allPanels = panels(); let allPanels = panels();

View file

@ -37,6 +37,7 @@ class MobileShellSettings : public QObject
// navigation panel // navigation panel
Q_PROPERTY(bool navigationPanelEnabled READ navigationPanelEnabled WRITE setNavigationPanelEnabled NOTIFY navigationPanelEnabledChanged) Q_PROPERTY(bool navigationPanelEnabled READ navigationPanelEnabled WRITE setNavigationPanelEnabled NOTIFY navigationPanelEnabledChanged)
Q_PROPERTY(bool alwaysShowKeyboardToggleOnNavigationPanel READ alwaysShowKeyboardToggleOnNavigationPanel WRITE setAlwaysShowKeyboardToggleOnNavigationPanel NOTIFY alwaysShowKeyboardToggleOnNavigationPanelChanged) Q_PROPERTY(bool alwaysShowKeyboardToggleOnNavigationPanel READ alwaysShowKeyboardToggleOnNavigationPanel WRITE setAlwaysShowKeyboardToggleOnNavigationPanel NOTIFY alwaysShowKeyboardToggleOnNavigationPanelChanged)
Q_PROPERTY(bool gesturePanelEnabled READ gesturePanelEnabled WRITE setGesturePanelEnabled NOTIFY gesturePanelEnabledChanged)
// action drawer // action drawer
Q_PROPERTY(ActionDrawerMode actionDrawerTopLeftMode READ actionDrawerTopLeftMode WRITE setActionDrawerTopLeftMode NOTIFY actionDrawerTopLeftModeChanged) Q_PROPERTY(ActionDrawerMode actionDrawerTopLeftMode READ actionDrawerTopLeftMode WRITE setActionDrawerTopLeftMode NOTIFY actionDrawerTopLeftModeChanged)
@ -172,6 +173,20 @@ public:
*/ */
void setNavigationPanelEnabled(bool navigationPanelEnabled); void setNavigationPanelEnabled(bool navigationPanelEnabled);
/**
* Whether the gesture panel is enabled.
*
* This is only applicable when navigationPanelEnabled() = false (gesture mode).
*/
bool gesturePanelEnabled() const;
/**
* Set whether the gesture panel is enabled.
*
* @param gesturePanelEnabled Whether the gesture panel should be enabled.
*/
void setGesturePanelEnabled(bool gesturePanelEnabled);
/** /**
* Set whether the keyboard toggle button should always show on the navigation panel, regardless of * Set whether the keyboard toggle button should always show on the navigation panel, regardless of
* whether the app properly supports virtual keyboards. * whether the app properly supports virtual keyboards.
@ -282,6 +297,7 @@ Q_SIGNALS:
void vibrationsEnabledChanged(); void vibrationsEnabledChanged();
void vibrationDurationChanged(); void vibrationDurationChanged();
void navigationPanelEnabledChanged(); void navigationPanelEnabledChanged();
void gesturePanelEnabledChanged();
void alwaysShowKeyboardToggleOnNavigationPanelChanged(); void alwaysShowKeyboardToggleOnNavigationPanelChanged();
void keyboardButtonEnabledChanged(); void keyboardButtonEnabledChanged();
void animationsEnabledChanged(); void animationsEnabledChanged();
@ -299,7 +315,7 @@ Q_SIGNALS:
void lockscreenRightButtonActionChanged(); void lockscreenRightButtonActionChanged();
private: private:
void updateNavigationBarsInPlasma(bool navigationPanelEnabled); void updateNavigationBarsInPlasma();
KConfigWatcher::Ptr m_configWatcher; KConfigWatcher::Ptr m_configWatcher;
KSharedConfig::Ptr m_config; KSharedConfig::Ptr m_config;

View file

@ -177,20 +177,48 @@ ContainmentItem {
property real offset: 0 property real offset: 0
Component {
id: navigationPanelComponent
NavigationPanelComponent {
isVertical: root.inLandscape
opaqueBar: root.opaqueBar
forcedComplementary: !opaqueBar && !startupFeedbackColorAnimation.isShowing
transform: [
Translate {
y: inLandscape ? 0 : navigationPanel.offset
x: inLandscape ? navigationPanel.offset : 0
}
]
}
}
Component {
id: gesturePanelComponent
MobileShell.GesturePanel {
opaqueBar: root.opaqueBar
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: (!opaqueBar && !startupFeedbackColorAnimation.isShowing) ? Kirigami.Theme.Complementary : Kirigami.Theme.Window
onHandlePressedAndHeld: MobileShellState.ShellDBusClient.openHomeScreen()
onHandleClicked: Plasmoid.triggerTaskSwitcher()
transform: [
Translate {
y: inLandscape ? 0 : navigationPanel.offset
x: inLandscape ? navigationPanel.offset : 0
}
]
}
}
// load appropriate system navigation component // load appropriate system navigation component
NavigationPanelComponent { Loader {
anchors.fill: parent anchors.fill: parent
sourceComponent: ShellSettings.Settings.navigationPanelEnabled ? navigationPanelComponent : gesturePanelComponent
isVertical: root.inLandscape
opaqueBar: root.opaqueBar
forcedComplementary: !opaqueBar && !startupFeedbackColorAnimation.isShowing
transform: [
Translate {
y: inLandscape ? 0 : navigationPanel.offset
x: inLandscape ? navigationPanel.offset : 0
}
]
} }
state: MobileShellState.ShellDBusClient.panelState state: MobileShellState.ShellDBusClient.panelState

View file

@ -239,6 +239,21 @@ KCM.SimpleKCM {
} }
} }
} }
FormCard.FormDelegateSeparator { visible: gesturePanelToggleDelegate.visible }
FormCard.FormSwitchDelegate {
id: gesturePanelToggleDelegate
visible: !ShellSettings.Settings.navigationPanelEnabled
text: i18n("Show gesture handle")
description: i18n("Whether to add a panel on the bottom with a gesture handle.")
checked: ShellSettings.Settings.gesturePanelEnabled
onCheckedChanged: {
if (checked != ShellSettings.Settings.gesturePanelEnabled) {
ShellSettings.Settings.gesturePanelEnabled = checked;
}
}
}
} }
} }
} }

View file

@ -482,31 +482,21 @@ FocusScope {
// anchors.right: parent.right // anchors.right: parent.right
// } // }
// navigation panel // Gesture panel
MobileShell.NavigationPanel { Component {
id: navigationPanel id: gesturePanelComponent
z: !root.taskSwitcherHelpers.currentlyBeingClosed ? 1 : 0
visible: ShellSettings.Settings.navigationPanelEnabled
backgroundColor: Qt.rgba(0, 0, 0, 0.1)
foregroundColorGroup: Kirigami.Theme.Complementary
shadow: false
isVertical: MobileShell.Constants.navigationPanelOnSide(root.width, root.height) MobileShell.GesturePanel {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
MobileShellState.PanelSettingsDBusClient { Kirigami.Theme.inherit: false
id: panelSettings Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
screenName: Screen.name opaqueBar: false
}
leftPadding: panelSettings.navigationPanelLeftPadding // Trigger home action on tap
rightPadding: panelSettings.navigationPanelRightPadding onHandleClicked: {
leftAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-task-switcher"
shrinkSize: 4
onTriggered: {
if (taskList.count === 0) { if (taskList.count === 0) {
root.hide(); root.hide();
} else { } else {
@ -522,27 +512,85 @@ FocusScope {
} }
} }
} }
}
// home button // Navigation panel
middleAction: MobileShell.NavigationPanelAction { Component {
enabled: true id: navigationPanelComponent
iconSource: "start-here-kde"
onTriggered: root.hide()
}
// close app/keyboard button MobileShell.NavigationPanel {
rightAction: MobileShell.NavigationPanelAction { id: navigationPanel
enabled: true backgroundColor: Qt.rgba(0, 0, 0, 0.1)
iconSource: "mobile-close-app" foregroundColorGroup: Kirigami.Theme.Complementary
shrinkSize: 4 shadow: false
onTriggered: { isVertical: MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
taskList.getTaskAt(root.state.currentTaskIndex).closeApp();
MobileShellState.PanelSettingsDBusClient {
id: panelSettings
screenName: Screen.name
}
leftPadding: panelSettings.navigationPanelLeftPadding
rightPadding: panelSettings.navigationPanelRightPadding
leftAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-task-switcher"
shrinkSize: 4
onTriggered: {
if (taskList.count === 0) {
root.hide();
} else {
if (taskList.count > 1 &&
root.state.elapsedTimeSinceStart != -1 &&
root.state.elapsedTimeSinceStart < root.state.doubleClickInterval) {
root.taskSwitcherHelpers.openApp(1);
return;
}
const currentIndex = root.state.currentTaskIndex;
root.taskSwitcherHelpers.openApp(root.state.currentTaskIndex);
}
}
}
// home button
middleAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "start-here-kde"
onTriggered: root.hide()
}
// close app/keyboard button
rightAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-close-app"
shrinkSize: 4
onTriggered: {
taskList.getTaskAt(root.state.currentTaskIndex).closeApp();
}
}
rightCornerAction: MobileShell.NavigationPanelAction {
visible: false
} }
} }
}
rightCornerAction: MobileShell.NavigationPanelAction { Loader {
visible: false id: panelLoader
z: !root.taskSwitcherHelpers.currentlyBeingClosed ? 1 : 0
sourceComponent: {
if (ShellSettings.Settings.navigationPanelEnabled) {
return navigationPanelComponent;
} else if (ShellSettings.Settings.gesturePanelEnabled) {
return gesturePanelComponent;
} else {
return null;
}
} }
} }
@ -551,7 +599,7 @@ FocusScope {
name: "landscape" name: "landscape"
when: MobileShell.Constants.navigationPanelOnSide(root.width, root.height) when: MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
AnchorChanges { AnchorChanges {
target: navigationPanel target: panelLoader
anchors { anchors {
right: root.right right: root.right
top: root.top top: root.top
@ -560,7 +608,7 @@ FocusScope {
} }
} }
PropertyChanges { PropertyChanges {
target: navigationPanel target: panelLoader
width: navRightMargin width: navRightMargin
anchors.topMargin: root.topMargin anchors.topMargin: root.topMargin
} }
@ -569,7 +617,7 @@ FocusScope {
name: "portrait" name: "portrait"
when: !MobileShell.Constants.navigationPanelOnSide(root.width, root.height) when: !MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
AnchorChanges { AnchorChanges {
target: navigationPanel target: panelLoader
anchors { anchors {
top: undefined top: undefined
right: root.right right: root.right
@ -578,7 +626,7 @@ FocusScope {
} }
} }
PropertyChanges { PropertyChanges {
target: navigationPanel target: panelLoader
height: navBottomMargin height: navBottomMargin
} }
} }

View file

@ -29,7 +29,10 @@ SceneEffect {
TaskSwitcherPlugin.MobileTaskSwitcherState { TaskSwitcherPlugin.MobileTaskSwitcherState {
id: taskSwitcherState id: taskSwitcherState
gestureEnabled: !ShellSettings.Settings.navigationPanelEnabled && !MobileShellState.ShellDBusClient.isActionDrawerOpen && !MobileShellState.ShellDBusClient.isVolumeOSDOpen && !MobileShellState.ShellDBusClient.isNotificationPopupDrawerOpen gestureEnabled: !ShellSettings.Settings.navigationPanelEnabled
&& !MobileShellState.ShellDBusClient.isActionDrawerOpen
&& !MobileShellState.ShellDBusClient.isVolumeOSDOpen
&& !MobileShellState.ShellDBusClient.isNotificationPopupDrawerOpen
Component.onCompleted: { Component.onCompleted: {
// Initialize with effect // Initialize with effect