shift-shell/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml
Luis Büchi 617ba59de7 add haptic feedback to task switcher gestures
haptics are triggered when conditions are met to open task switcher or task scrub mode is engaged.
fixes related bug in task switcher gesture logic when invoked from homescreen

fixes https://invent.kde.org/plasma/plasma-mobile/-/issues/366
2024-07-24 10:57:57 +00:00

588 lines
23 KiB
QML

// SPDX-FileCopyrightText: 2015 Marco Martin <notmart@gmail.com>
// SPDX-FileCopyrightText: 2021-2024 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami 2.20 as Kirigami
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents
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.private.mobileshell.taskswitcher 1.0 as TaskSwitcherData
import org.kde.kwin 3.0 as KWinComponents
import org.kde.kwin.private.effects 1.0
import org.kde.kitemmodels
/**
* Component that provides a task switcher.
*/
FocusScope {
id: root
focus: true
readonly property QtObject effect: KWinComponents.SceneView.effect
readonly property TaskSwitcherData.TaskSwitcherState state: TaskSwitcherData.TaskSwitcherState
readonly property QtObject targetScreen: KWinComponents.SceneView.screen
readonly property real topMargin: MobileShell.Constants.topPanelHeight
readonly property real bottomMargin: MobileShell.Constants.navigationPanelOnSide(width, height) ? 0 : MobileShell.Constants.navigationPanelThickness
readonly property real leftMargin: 0
readonly property real rightMargin: MobileShell.Constants.navigationPanelOnSide(width, height) ? MobileShell.Constants.navigationPanelThickness : 0
property var taskSwitcherHelpers: TaskSwitcherHelpers {
taskSwitcher: root
stateClass: TaskSwitcherData.TaskSwitcherState
}
MobileShell.HapticsEffect {
id: haptics
}
property var tasksModel: TaskSwitcherData.TaskFilterModel {
screenName: root.targetScreen.name
windowModel: TaskSwitcherData.TaskModel
}
readonly property int tasksCount: taskList.count
// keep track of task list events
property int oldTasksCount: tasksCount
onTasksCountChanged: {
if (tasksCount === 0 && oldTasksCount !== 0) {
hide();
} else if (tasksCount < oldTasksCount && state.currentTaskIndex >= tasksCount) {
// if the user is on the last task, and it is closed, scroll left
taskSwitcherHelpers.animateGoToTaskIndex(tasksCount - 1, Kirigami.Units.longDuration);
}
oldTasksCount = tasksCount;
}
Keys.onEscapePressed: hide();
Component.onCompleted: {
state.updateWasInActiveTask(KWinComponents.Workspace.activeWindow);
// task index from last time using the switcher
state.initialTaskIndex = Math.min(state.currentTaskIndex, tasksCount - 1);
if (state.wasInActiveTask) {
// if we were in an active task instead set initial task index to the position of that task
state.initialTaskIndex = taskSwitcherHelpers.getTaskIndexFromWindow(KWinComponents.Workspace.activeWindow);
}
taskSwitcherHelpers.goToTaskIndex(state.initialTaskIndex);
taskList.minimizeAll();
// fully open the switcher (if this is a button press, not gesture)
if (!root.state.gestureInProgress) {
taskSwitcherHelpers.open();
}
}
// called by c++ plugin
function hideAnimation() {
closeAnim.restart();
}
function instantHide() {
root.effect.deactivate(true);
}
function hide() {
root.effect.deactivate(false);
}
Connections {
target: root.state
// task scrub mode allows scrubbing through a number of tasks with a mostly horizontal motion
function taskScrubMode() {
if (!taskSwitcherHelpers.isInTaskScrubMode) {
taskSwitcherHelpers.isInTaskScrubMode = true;
taskSwitcherHelpers.cancelAnimations();
taskSwitcherHelpers.open();
if (!taskSwitcherHelpers.hasVibrated) {
// Haptic feedback when the task scrub mode engages
haptics.buttonVibrate();
taskSwitcherHelpers.hasVibrated = true;
}
}
let newTaskIndex = Math.max(0, Math.min(tasksCount - 1, Math.floor(state.touchXPosition / taskSwitcherHelpers.taskScrubDistance) + state.initialTaskIndex));
if (newTaskIndex != state.currentTaskIndex) {
taskSwitcherHelpers.animateGoToTaskIndex(newTaskIndex);
}
}
function onTouchPositionChanged() {
if (taskSwitcherHelpers.isInTaskScrubMode || // once in scrub mode, let's not allow to go out, that can result in inconsistent UX
(Math.abs(state.xVelocity) > Math.abs(state.yVelocity) * 3 && // gesture needs to be almost completely horizontal
Math.abs(state.xVelocity) < 2.5 && // and not with a fast flick TODO! evaluate whether to keep this, it's kinda awkward
Math.abs(state.touchXPosition) > taskSwitcherHelpers.taskScrubDistance * 0.95 && // and have moved far enough sideways
state.yPosition < taskSwitcherHelpers.undoYThreshold && // and be close to the screen edge
tasksCount > 1 // and there needs to be more than one task open
)) {
taskScrubMode();
} else {
if (state.status == TaskSwitcherData.TaskSwitcherState.Active) {
// task switcher is already open
// TODO add some sort of feedback for dismissing task switcher (maybe opacity reduction?)
return;
}
state.yPosition = Math.abs(state.touchYPosition);
state.xPosition = taskSwitcherHelpers.xPositionFromTaskIndex(state.initialTaskIndex) - state.touchXPosition;
}
}
// actions on an upwards flick
function upwardsFlick() {
if (state.wasInActiveTask) {
// go to homescreen if we were in an active task
taskSwitcherHelpers.close();
} else {
// or normally open task switcher if we were on the homescreen already
taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex);
taskSwitcherHelpers.open();
}
}
// returns to the currently centered app. usually used to "back out" of the switcher
// if accidentally invoked, but can also be used to switch to an adjacent app and then open it
function returnToApp() {
let newIndex = taskSwitcherHelpers.getNearestTaskIndex();
let appAtNewIndex = taskList.getTaskAt(newIndex).window;
taskSwitcherHelpers.openApp(newIndex, appAtNewIndex);
}
// diagonal quick switch gesture logic
function quickSwitch() {
// should "quick switch" to adjacent app in task switcher, but only if we were in an app before
let newIndex = state.currentTaskIndex;
let shouldSwitch = false;
if (state.xVelocity > 0 && state.wasInActiveTask) {
// flick to the right, go to app to the left
newIndex = state.currentTaskIndex + 1;
if (newIndex < tasksCount) {
// switch only if flick doesn't go over end of list
shouldSwitch = true;
}
} else if (state.xVelocity < 0 && state.wasInActiveTask) {
// flick to the left, go to app to the right
newIndex = state.currentTaskIndex - 1;
if (newIndex >= 0) {
// switch only if flick doesn't go over end of list
shouldSwitch = true;
}
}
if (shouldSwitch) {
let appAtNewIndex = taskList.getTaskAt(newIndex).window;
taskSwitcherHelpers.openApp(newIndex, appAtNewIndex, Kirigami.Units.longDuration * 4, Easing.OutExpo);
} else {
// if not switching, just open task switcher
taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex);
taskSwitcherHelpers.open();
}
}
// Logic for deciding how to handle the end of a gesture input
function onGestureInProgressChanged() {
if (state.gestureInProgress) {
return;
}
if (state.status == TaskSwitcherData.TaskSwitcherState.Active) {
if (taskSwitcherHelpers.isInTaskScrubMode) {
// TODO! do we want to handle upwards flick to dismiss in task scrub mode?
// TODO do we want to show a list of thumbnails in task scrub mode?
taskSwitcherHelpers.openApp(state.currentTaskIndex, taskList.getTaskAt(state.currentTaskIndex).window);
} else if (state.yPosition > taskSwitcherHelpers.undoYThreshold) {
// close task switcher if it was already open but only if swipe was higher than the undo threshold
taskSwitcherHelpers.close();
return;
}
} else if (state.status == TaskSwitcherData.TaskSwitcherState.Inactive) {
if (state.totalSquaredVelocity > state.flickVelocityThreshold) {
// flick
// ratio between y and x velocity as threshold between vertical and horizontal flick
let xyVelocityRatio = 1.7; // with 1.7 swipes up to ~60° from horizontal are counted as horizontal
if (-state.yVelocity > Math.abs(state.xVelocity) * xyVelocityRatio) {
upwardsFlick();
} else if (state.yVelocity > Math.abs(state.xVelocity) * xyVelocityRatio) {
// downwards flick
returnToApp();
} else {
// sideways flick
quickSwitch();
}
} else {
// no flick
if (state.yPosition > taskSwitcherHelpers.undoYThreshold) {
// normal task switcher open
taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex);
taskSwitcherHelpers.open();
} else {
// no flick and not enough activation to go to task switcher
if (state.wasInActiveTask) {
returnToApp();
} else {
// do open switcher in case we were on homescreen before
taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex);
taskSwitcherHelpers.open();
}
}
}
}
}
function onVelocityChanged() {
if (!taskSwitcherHelpers.hasVibrated) {
if (!state.wasInActiveTask ||
(state.wasInActiveTask &&
state.yPosition > taskSwitcherHelpers.undoYThreshold &&
state.totalSquaredVelocity < state.flickVelocityThreshold)) {
// Haptic feedback when conditions are met for the task switcher to open
haptics.buttonVibrate();
taskSwitcherHelpers.hasVibrated = true;
}
}
}
function onXPositionChanged() {
taskSwitcherHelpers.updateTaskIndex();
}
}
// view of the desktop background
KWinComponents.DesktopBackground {
id: backgroundItem
activity: KWinComponents.Workspace.currentActivity
desktop: KWinComponents.Workspace.currentDesktop
outputName: targetScreen.name
}
// background colour
Rectangle {
id: backgroundRect
anchors.fill: parent
opacity: container.opacity
color: {
// animate background colour only if we are *not* opening from the homescreen
if (state.wasInActiveTask || !state.currentlyBeingOpened) {
return Qt.rgba(0, 0, 0, 0.6);
} else {
return Qt.rgba(0, 0, 0, 0.6 * Math.min(1, state.yPosition / state.openedYPosition));
}
}
}
// status bar
// TODO: improve load times, it is quite slow
// MobileShell.StatusBar {
// id: statusBar
// z: 1
// colorGroup: Kirigami.Theme.ComplementaryColorGroup
// backgroundColor: "transparent"
//
// height: root.topMargin
// anchors.top: parent.top
// anchors.left: parent.left
// anchors.right: parent.right
// }
// navigation panel
MobileShell.NavigationPanel {
id: navigationPanel
z: 1
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)
leftAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-task-switcher"
iconSizeFactor: 0.75
onTriggered: {
if (taskList.count === 0) {
root.hide();
} else {
const currentIndex = state.currentTaskIndex;
taskSwitcherHelpers.openApp(state.currentTaskIndex, taskList.getTaskAt(currentIndex).window);
}
}
}
// home button
middleAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "start-here-kde"
iconSizeFactor: 1
onTriggered: root.hide()
}
// close app/keyboard button
rightAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-close-app"
iconSizeFactor: 0.75
onTriggered: {
taskList.getTaskAt(state.currentTaskIndex).closeApp();
}
}
rightCornerAction: MobileShell.NavigationPanelAction {
visible: false
}
}
states: [
State {
name: "landscape"
when: MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
AnchorChanges {
target: navigationPanel
anchors {
right: root.right
top: root.top
bottom: root.bottom
left: undefined
}
}
PropertyChanges {
target: navigationPanel
width: root.rightMargin
anchors.topMargin: root.topMargin
}
},
State {
name: "portrait"
when: !MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
AnchorChanges {
target: navigationPanel
anchors {
top: undefined
right: root.right
left: root.left
bottom: root.bottom
}
}
PropertyChanges {
target: navigationPanel
height: root.bottomMargin
}
}
]
// task list
Item {
id: container
// provide shell margins
anchors.fill: parent
anchors.leftMargin: root.leftMargin
anchors.rightMargin: root.rightMargin
anchors.bottomMargin: root.bottomMargin
anchors.topMargin: root.topMargin
NumberAnimation on opacity {
id: closeAnim
running: false
to: 0
duration: 200
easing.type: Easing.InOutQuad
onFinished: {
closeAllButton.closeRequested = false;
}
}
// placeholder message
ColumnLayout {
id: placeholder
spacing: Kirigami.Units.gridUnit
opacity: (root.tasksCount === 0 && !taskSwitcherHelpers.currentlyBeingClosed) ? 0.9 : 0
Behavior on opacity { NumberAnimation { duration: 500 } }
anchors.centerIn: parent
Kirigami.Icon {
id: icon
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.large
implicitHeight: Kirigami.Units.iconSizes.large
source: "window"
color: "white"
}
Kirigami.Heading {
Layout.fillWidth: true
Layout.maximumWidth: root.width * 0.75
Layout.alignment: Qt.AlignHCenter
color: "white"
level: 3
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
text: i18n("No applications are running.")
}
}
RowLayout {
id: scrubIconList
opacity: taskSwitcherHelpers.isInTaskScrubMode ? 1 : 0
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration } }
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: taskSwitcherHelpers.openedYPosition * 5 / 8
anchors.horizontalCenterOffset: {
let size = Kirigami.Units.iconSizes.large + Kirigami.Units.largeSpacing * 2;
let offset = (root.state.currentTaskIndex - 0.5) * size;
return offset;
}
Behavior on anchors.horizontalCenterOffset {
NumberAnimation {
// TODO: this duration should track the duration of xAnim but that is variable through function parameter
// how do we make sure this is always the same duration?
duration: Kirigami.Units.longDuration * 2;
easing.type: Easing.OutBack;
}
}
spacing: Kirigami.Units.largeSpacing * 2
layoutDirection: Qt.RightToLeft
Repeater {
model: root.tasksModel
delegate: Kirigami.Icon {
id: iconDelegate
required property QtObject window
required property int index
readonly property bool isCenteredIcon: iconDelegate.index === root.state.currentTaskIndex;
Layout.preferredHeight: isCenteredIcon ? Kirigami.Units.iconSizes.huge : Kirigami.Units.iconSizes.large
Layout.preferredWidth: isCenteredIcon ? Kirigami.Units.iconSizes.huge : Kirigami.Units.iconSizes.large
Layout.alignment: Qt.AlignVCenter
source: iconDelegate.window.icon
}
}
}
RowLayout {
id: scrubIndicator
opacity: taskSwitcherHelpers.isInTaskScrubMode ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: taskSwitcherHelpers.openedYPosition * 1 / 4
Kirigami.Icon {
id: iconScrubBack
opacity: root.state.currentTaskIndex == 0 ? 0.3 : 1
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration * 2; easing.type: Easing.OutExpo } }
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: Kirigami.Units.iconSizes.medium
source: "draw-arrow-back"
color: "white"
}
Item {
width: taskSwitcherHelpers.windowWidth / 4
}
Kirigami.Icon {
id: iconScrubFront
opacity: root.state.currentTaskIndex == tasksCount - 1 ? 0.3 : 1
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration * 2; easing.type: Easing.OutExpo } }
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: Kirigami.Units.iconSizes.medium
source: "draw-arrow-forward"
color: "white"
}
}
// flicking area for task switcher
FlickContainer {
id: flickable
anchors.fill: parent
taskSwitcherState: root.state
taskSwitcherHelpers: root.taskSwitcherHelpers
// don't allow FlickContainer to steal from swiping on tasks
interactive: taskList.taskInteractingCount === 0
// the item is effectively anchored to the flickable bounds
TaskList {
id: taskList
taskSwitcher: root
shellTopMargin: root.topMargin
shellBottomMargin: root.bottomMargin
opacity: {
// animate opacity only if we are *not* opening from the homescreen
// TODO! do we really not want to animate it always? it's a bit harsh to look at when opening from homescreen
if (state.wasInActiveTask || !state.currentlyBeingOpened) {
return 1;
} else {
return Math.min(1, state.yPosition / state.openedYPosition);
}
}
x: flickable.contentX
width: flickable.width
height: flickable.height
PlasmaComponents.ToolButton {
id: closeAllButton
property bool closeRequested: false
visible: root.tasksCount !== 0 && !taskSwitcherHelpers.isInTaskScrubMode
anchors {
bottom: parent.bottom
bottomMargin: (taskList.taskY + taskList.trackFingerYOffset) / 2
horizontalCenter: parent.horizontalCenter
}
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
opacity: (taskSwitcherHelpers.currentlyBeingClosed) ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } }
icon.name: "edit-clear-history"
font.bold: true
text: closeRequested ? i18n("Confirm Close All") : i18n("Close All")
onClicked: {
if (closeRequested) {
taskList.closeAll();
} else {
closeRequested = true;
}
}
}
}
}
}
}