mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-04-26 06:13:09 +00:00
382 lines
15 KiB
QML
382 lines
15 KiB
QML
// SPDX-FileCopyrightText: 2021 Devin Lin <devin@kde.org>
|
|
// SPDX-FileCopyrightText: 2024-2025 Luis Büchi <luis.buechi@kdemail.net>
|
|
// 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();
|
|
}
|
|
}
|
|
}
|