// SPDX-FileCopyrightText: 2021 Devin Lin // SPDX-FileCopyrightText: 2024-2025 Luis Büchi // SPDX-License-Identifier: GPL-2.0-or-later pragma ComponentBehavior: Bound import QtQuick 2.15 import org.kde.kirigami as Kirigami import org.kde.plasma.private.mobileshell.taskswitcherplugin as TaskSwitcherPlugin import org.kde.kwin 3.0 as KWinComponents /** * State object for the task switcher. */ QtObject { id: root // TaskSwitcher item component // We assume that the taskSwitcher is the size of the entire screen. required property var taskSwitcher property var state: taskSwitcher.state required property var taskList // task switcher peek and pop setting for when it is toggled from the home screen readonly property real peekOffsetValue: 1.85 readonly property real homeOffsetValue: 2.6 readonly property real taskOffsetValue: 1.5 // the index of the last task to be closed // this will get reset to -1 in TaskSwitcher.qml when the TasksCount changes property int lastClosedTask: -1 // this will only gets set to the currentTaskIndex when the gesture starts // this helps remove visual glitches as the gesture animations play out and the currentTaskIndex changes property int currentDisplayTask: state.currentTaskIndex // how much the the task will resist shrinking // value of 1 will match the y position resistance one to one readonly property real scaleResistance: 0.5 // direction of the movement readonly property bool gestureMovingRight: state.xVelocity > 0 readonly property bool gestureMovingUp: state.yVelocity < 0 readonly property bool currentlyBeingOpened: state.gestureInProgress || openAnim.running readonly property bool currentlyBeingClosed: closeAnim.running || openAppAnim.running || inLastFrame // indicates whether taskswitcher is in last frame before dismissal. this happens after open app or close animation // and is required because openApp/close animation isRunning is false in said last frame which can lead to visual // glitches in said frame. property bool inLastFrame: false // yPosition when the task switcher is completely open readonly property real openedYPosition: Math.round((taskSwitcher.height - taskHeight) / 2) readonly property real scrubModeOverrun: { return Math.max(0, scrubModeBottomMargin + Kirigami.Units.iconSizes.huge + Kirigami.Units.smallSpacing - openedYPosition); } readonly property real scrubModeBottomMargin: { return Math.round(openedYPosition * 5 / 8); } // yPosition threshold below which opening the task switcher should be undone and returned to the previously active task readonly property real undoYThreshold: openedYPosition / 2 // the height threshold where if the yPosition is above this value the task switch will return home readonly property real heightThreshold: windowHeight * 0.55 // whether the switcher is opened or not readonly property bool taskDrawerOpened: state.status == TaskSwitcherPlugin.MobileTaskSwitcherState.Active // This is true when the task drawer is already opened or if within an app readonly property bool notHomeScreenState: state.wasInActiveTask || taskDrawerOpened // set to true if the taskSwitcher is opened by the navbar button property bool fromButton: false // gets set to true after 1 milliseconds property bool taskSwitchCanLaunch: false // whether the switcher has already triggered haptic feedback or not // we don't want to continuously send haptics, just once is enough property bool hasVibrated: false // The current gesture state to decide what will happen when it is completed enum GestureStates { Undecided, HorizontalSwipe, TaskSwitcher, Home } property int gestureState: TaskSwitcherHelpers.GestureStates.Undecided // if the touch has reached the height threshold property bool reachedHeightThreshold: false // made as variables to keep x anim in task list and task scrub icon list in sync property int xAnimDuration: Kirigami.Units.longDuration * 2 property int xAnimEasingType: Easing.OutExpo // ~~ measurement constants ~~ // dimensions of a real window on the screen readonly property real windowHeight: taskSwitcher.height - taskSwitcher.topMargin - taskSwitcher.bottomMargin readonly property real windowWidth: taskSwitcher.width - taskSwitcher.leftMargin - taskSwitcher.rightMargin // dimensions of the task previews readonly property real previewHeight: windowHeight * scalingFactor readonly property real previewWidth: windowWidth * scalingFactor readonly property real previewAspectRatio: previewWidth / previewHeight readonly property real taskHeight: previewHeight + taskHeaderHeight readonly property real taskWidth: previewWidth // spacing between each task preview readonly property real taskSpacing: Kirigami.Units.gridUnit // height of the task preview header readonly property real taskHeaderHeight: Kirigami.Units.gridUnit * 2 + Kirigami.Units.smallSpacing * 2 // finger position y with resistance readonly property real trackFingerYOffset: { if (isScaleClamped) { let directTrackingOffset = openedYPosition * 0.2 if (root.state.yPosition < openedYPosition + directTrackingOffset) { // Allow the task list to move further up than the fully opened position return root.state.yPosition - openedYPosition; } else { // but make it more reluctant the further up it goes let overDragProgress = (root.state.yPosition - directTrackingOffset - openedYPosition) / openedYPosition; // Base formula is 1-2.3^(-progress) which asymptotically approaches 1 return (1 - Math.pow(2.3, -overDragProgress)) * openedYPosition + directTrackingOffset; } } else { return 0; } } // the scaling factor of the window preview compared to the actual window // we need to ensure that window previews always fit on screen readonly property real scalingFactor: { let candidateFactor = 0.6; let candidateTaskHeight = windowHeight * candidateFactor + taskHeaderHeight; let candidateTaskWidth = windowWidth * candidateFactor; let candidateHeight = (candidateTaskWidth / windowWidth) * windowHeight; if (candidateHeight > windowHeight) { return candidateTaskHeight / windowHeight; } else { return candidateTaskWidth / windowWidth; } } // the closing factor during the closing of the switcher property real closingFactor: 1 // scaling factor during the closing of the switcher property real closingScalingFactor: 1 // scale of the task list (based on the progress of the swipe up gesture) readonly property real currentScale: { let maxScale = 1 / scalingFactor; let subtract = (maxScale - 1) * ((Math.min(root.state.yPosition, openedYPosition) + trackFingerYOffset * scaleResistance) / openedYPosition) let finalScale = Math.min(maxScale, maxScale - subtract); // if closing scaling factor is below 1 we want it to override the other scale // to allow for a smoother closing animation if (closingScalingFactor < 1 && (root.state.wasInActiveTask || root.taskDrawerOpened)) { return closingScalingFactor; } return finalScale; } readonly property bool isScaleClamped: root.state.yPosition > openedYPosition readonly property real taskScrubDistance: windowWidth / (2 * 6) // formula says how many tasks can be scrubbed through in half of the window width property bool isInTaskScrubMode: false // ~~ signals and functions ~~ // cancel all animated moving, as another flick source is taking over signal cancelAnimations() onCancelAnimations: { openAppAnim.stop(); closeAnim.stop(); closeScaleAnim.stop(); closeFactorAnim.stop(); xAnim.stop(); } function getTaskIndexFromWindow(window: KWinComponents.Window): int { for (let i = 0; i < taskSwitcher.tasksModel.rowCount(); i++) { const modelWindow = taskSwitcher.tasksModel.data(taskSwitcher.tasksModel.index(i, 0), Qt.DisplayRole); if (modelWindow == window) { return i; } } return 0; } function getTaskIndexFromXPosition(): int { let candidateIndex = Math.round(-root.state.xPosition / (taskSpacing + taskWidth)); return Math.max(0, Math.min(taskSwitcher.tasksCount - 1, candidateIndex)); } // TODO either use updateTaskIndex to always have the "newest current task index" in the state var or use "getNearestTaskIndex", not both it's redundant function updateTaskIndex(): void { // only set if not gesture currently in progress to prevent glitching if (!(state.gestureInProgress || root.closeAnim.running || root.openAppAnim.running) || root.isInTaskScrubMode) { root.state.currentTaskIndex = getTaskIndexFromXPosition(); } } function open(): void { root.gestureState = TaskSwitcherHelpers.GestureStates.TaskSwitcher; openAnim.restart(); // update the task offset position taskList.setTaskOffsetValue(0, false, Easing.OutQuart); } function close(): void { // update the task offset position taskList.setTaskOffsetValue(homeOffsetValue + 0.25, false, Easing.Linear); root.gestureState = TaskSwitcherHelpers.GestureStates.Undecided; cancelAnimations(); closingScalingFactor = currentScale; closeAnim.restart(); closeScaleAnim.restart(); closeFactorAnim.restart(); } function openApp(index: int, duration = Kirigami.Units.shortDuration, horizontalEasing = Easing.OutBack): void { // cancel any opening animations ongoing openAnim.stop(); cancelAnimations(); animateGoToTaskIndex(index, duration); openAppAnim.restart(); KWinComponents.Workspace.activeWindow = taskList.getTaskAt(index).window; } // get the xPosition where the task will be centered on the screen function xPositionFromTaskIndex(index: int): int { return -index * (taskWidth + taskSpacing); } // instantly go to the task index function goToTaskIndex(index: int): void { root.state.xPosition = xPositionFromTaskIndex(index); } // go to the task index, animated function animateGoToTaskIndex(index: int, duration = Kirigami.Units.longDuration * 2, easing = Easing.OutExpo): void { xAnimDuration = duration; xAnimEasingType = easing; xAnim.to = xPositionFromTaskIndex(index) - (gestureState == TaskSwitcherHelpers.GestureStates.HorizontalSwipe && !state.gestureInProgress && notHomeScreenState ? taskSpacing / 2 : 0); xAnim.restart(); } function getNearestTaskIndex(): int { let newTaskIndex = getTaskIndexFromXPosition(); let currentTaskIndexPosition = xPositionFromTaskIndex(root.state.currentTaskIndex); if (root.state.xPosition > currentTaskIndexPosition) { // moving to task further to the right if (newTaskIndex != root.state.currentTaskIndex) { // reset back to current task index return root.state.currentTaskIndex; } else { // animate snapping to new task index return Math.max(0, newTaskIndex); } } else { // moving to task further to the left if (newTaskIndex != root.state.currentTaskIndex) { // animate snapping to new task index return Math.min(taskSwitcher.tasksCount - 1, newTaskIndex); } else { // reset back to current task index return root.state.currentTaskIndex; } } } function snapToNearestTask(): void { let index = getNearestTaskIndex(); animateGoToTaskIndex(index); } // This is a workaround for flickable not actually flicking, so we just snap to the next task // based on old movement direction, ignoring momentum (because flickable doesn't give us any momentum) function snapToNearestTaskWorkaround(movingRight: bool): void { let currentTaskIndexPosition = xPositionFromTaskIndex(root.state.currentTaskIndex); if (root.state.xPosition > currentTaskIndexPosition) { if (movingRight) { animateGoToTaskIndex(root.state.currentTaskIndex); } else { animateGoToTaskIndex(Math.max(0, root.state.currentTaskIndex - 1)); } } else { if (movingRight) { animateGoToTaskIndex(Math.min(taskSwitcher.tasksCount - 1, root.state.currentTaskIndex + 1)); } else { animateGoToTaskIndex(root.state.currentTaskIndex); } } } // ~~ property animators ~~ property var xAnim: NumberAnimation { target: root.state property: "xPosition" duration: root.xAnimDuration easing.type: root.xAnimEasingType } property var openAnim: NumberAnimation { target: root.state property: "yPosition" to: root.openedYPosition duration: 250 easing.type: Easing.OutQuart onFinished: { if (!root.isInTaskScrubMode) { root.state.status = TaskSwitcherPlugin.MobileTaskSwitcherState.Active; } } } // TODO: This animation should ideally be replaced by some // speed tracking to track finger movement better. Until then // InBack at least pretends to go in the finger move direction property var closeAnim: NumberAnimation { target: root.state property: "yPosition" to: 0 duration: Kirigami.Units.longDuration easing.type: Easing.InBack onFinished: { root.inLastFrame = true; root.state.status = TaskSwitcherPlugin.MobileTaskSwitcherState.Inactive; root.taskSwitcher.instantHide(); } } property var closeScaleAnim: NumberAnimation { target: root property: "closingScalingFactor" to: 0.1 duration: Kirigami.Units.longDuration easing.type: Easing.InQuad onStopped: { root.closingScalingFactor = 1; } } property var closeFactorAnim: NumberAnimation { target: root property: "closingFactor" to: 0 duration: Kirigami.Units.longDuration easing.type: Easing.InQuad } property var openAppAnim: NumberAnimation { target: root.state property: "yPosition" to: 0 duration: 300 easing.type: Easing.OutQuint onFinished: { root.inLastFrame = true; root.state.status = TaskSwitcherPlugin.MobileTaskSwitcherState.Inactive; root.taskSwitcher.instantHide(); } } }