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/WallpaperSelector.qml
qml/navigationpanel/GesturePanel.qml
qml/navigationpanel/NavigationPanel.qml
qml/navigationpanel/NavigationPanelAction.qml
qml/navigationpanel/NavigationPanelButton.qml

View file

@ -28,11 +28,13 @@ QtObject {
}
readonly property real defaultNavigationPanelThickness: Kirigami.Units.gridUnit * 2
readonly property real defaultGesturePanelThickness: Kirigami.Units.gridUnit
readonly property real navigationPanelThickness: {
if (!ShellSettings.Settings.navigationPanelEnabled) {
return 0;
} else if (root.panelSettings.navigationPanelHeight <= 0) {
return ShellSettings.Settings.gesturePanelEnabled ? defaultGesturePanelThickness : 0;
}
if (root.panelSettings.navigationPanelHeight <= 0) {
return defaultNavigationPanelThickness;
}
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 showBatteryPercentageChanged();
Q_EMIT navigationPanelEnabledChanged();
Q_EMIT gesturePanelEnabledChanged();
Q_EMIT alwaysShowKeyboardToggleOnNavigationPanelChanged();
Q_EMIT keyboardButtonEnabledChanged();
Q_EMIT taskSwitcherPreviewsEnabledChanged();
@ -145,7 +146,22 @@ void MobileShellSettings::setNavigationPanelEnabled(bool navigationPanelEnabled)
group.writeEntry("navigationPanelEnabled", navigationPanelEnabled, KConfigGroup::Notify);
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
@ -232,7 +248,7 @@ void MobileShellSettings::setAutoHidePanelsEnabled(bool enabled)
m_config->sync();
}
void MobileShellSettings::updateNavigationBarsInPlasma(bool navigationPanelEnabled)
void MobileShellSettings::updateNavigationBarsInPlasma()
{
// Do not update panels when not in Plasma Mobile
bool isMobilePlatform = KRuntimePlatform::runtimePlatform().contains("phone");
@ -245,13 +261,22 @@ void MobileShellSettings::updateNavigationBarsInPlasma(bool navigationPanelEnabl
QLatin1String("org.kde.PlasmaShell"),
QLatin1String("evaluateScript"));
if (navigationPanelEnabled) {
if (navigationPanelEnabled() || gesturePanelEnabled()) {
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;
} else {
QString deleteNavigationPanelScript = R"(
let allPanels = panels();

View file

@ -37,6 +37,7 @@ class MobileShellSettings : public QObject
// navigation panel
Q_PROPERTY(bool navigationPanelEnabled READ navigationPanelEnabled WRITE setNavigationPanelEnabled NOTIFY navigationPanelEnabledChanged)
Q_PROPERTY(bool alwaysShowKeyboardToggleOnNavigationPanel READ alwaysShowKeyboardToggleOnNavigationPanel WRITE setAlwaysShowKeyboardToggleOnNavigationPanel NOTIFY alwaysShowKeyboardToggleOnNavigationPanelChanged)
Q_PROPERTY(bool gesturePanelEnabled READ gesturePanelEnabled WRITE setGesturePanelEnabled NOTIFY gesturePanelEnabledChanged)
// action drawer
Q_PROPERTY(ActionDrawerMode actionDrawerTopLeftMode READ actionDrawerTopLeftMode WRITE setActionDrawerTopLeftMode NOTIFY actionDrawerTopLeftModeChanged)
@ -172,6 +173,20 @@ public:
*/
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
* whether the app properly supports virtual keyboards.
@ -282,6 +297,7 @@ Q_SIGNALS:
void vibrationsEnabledChanged();
void vibrationDurationChanged();
void navigationPanelEnabledChanged();
void gesturePanelEnabledChanged();
void alwaysShowKeyboardToggleOnNavigationPanelChanged();
void keyboardButtonEnabledChanged();
void animationsEnabledChanged();
@ -299,7 +315,7 @@ Q_SIGNALS:
void lockscreenRightButtonActionChanged();
private:
void updateNavigationBarsInPlasma(bool navigationPanelEnabled);
void updateNavigationBarsInPlasma();
KConfigWatcher::Ptr m_configWatcher;
KSharedConfig::Ptr m_config;

View file

@ -177,20 +177,48 @@ ContainmentItem {
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
NavigationPanelComponent {
Loader {
anchors.fill: parent
isVertical: root.inLandscape
opaqueBar: root.opaqueBar
forcedComplementary: !opaqueBar && !startupFeedbackColorAnimation.isShowing
transform: [
Translate {
y: inLandscape ? 0 : navigationPanel.offset
x: inLandscape ? navigationPanel.offset : 0
}
]
sourceComponent: ShellSettings.Settings.navigationPanelEnabled ? navigationPanelComponent : gesturePanelComponent
}
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
// }
// navigation panel
MobileShell.NavigationPanel {
id: navigationPanel
z: !root.taskSwitcherHelpers.currentlyBeingClosed ? 1 : 0
visible: ShellSettings.Settings.navigationPanelEnabled
backgroundColor: Qt.rgba(0, 0, 0, 0.1)
foregroundColorGroup: Kirigami.Theme.Complementary
shadow: false
// Gesture panel
Component {
id: gesturePanelComponent
isVertical: MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
MobileShell.GesturePanel {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
MobileShellState.PanelSettingsDBusClient {
id: panelSettings
screenName: Screen.name
}
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
opaqueBar: false
leftPadding: panelSettings.navigationPanelLeftPadding
rightPadding: panelSettings.navigationPanelRightPadding
leftAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-task-switcher"
shrinkSize: 4
onTriggered: {
// Trigger home action on tap
onHandleClicked: {
if (taskList.count === 0) {
root.hide();
} else {
@ -522,27 +512,85 @@ FocusScope {
}
}
}
}
// home button
middleAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "start-here-kde"
onTriggered: root.hide()
}
// Navigation panel
Component {
id: navigationPanelComponent
// close app/keyboard button
rightAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-close-app"
shrinkSize: 4
MobileShell.NavigationPanel {
id: navigationPanel
backgroundColor: Qt.rgba(0, 0, 0, 0.1)
foregroundColorGroup: Kirigami.Theme.Complementary
shadow: false
onTriggered: {
taskList.getTaskAt(root.state.currentTaskIndex).closeApp();
isVertical: MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
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 {
visible: false
Loader {
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"
when: MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
AnchorChanges {
target: navigationPanel
target: panelLoader
anchors {
right: root.right
top: root.top
@ -560,7 +608,7 @@ FocusScope {
}
}
PropertyChanges {
target: navigationPanel
target: panelLoader
width: navRightMargin
anchors.topMargin: root.topMargin
}
@ -569,7 +617,7 @@ FocusScope {
name: "portrait"
when: !MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
AnchorChanges {
target: navigationPanel
target: panelLoader
anchors {
top: undefined
right: root.right
@ -578,7 +626,7 @@ FocusScope {
}
}
PropertyChanges {
target: navigationPanel
target: panelLoader
height: navBottomMargin
}
}

View file

@ -29,7 +29,10 @@ SceneEffect {
TaskSwitcherPlugin.MobileTaskSwitcherState {
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: {
// Initialize with effect