mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-04-26 14:23:09 +00:00
doing a quick task switch gesture towards the end of the list should force open the task switcher (there is nothing to switch to) this was half-done on the left side of the switcher (this behavior is not visible with immediate task reordering, but could be hit if we have different ordering criteria or delayed reordering) and due to superfluous Math.max completely not working on the right side always opening the right-most task instead of opening the switcher
557 lines
21 KiB
QML
557 lines
21 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
|
|
}
|
|
|
|
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();
|
|
}
|
|
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);
|
|
} 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
|
|
returnToApp();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|