From 8f8bcfd8be07c2467bba88f0258bdb7c3beec15c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20B=C3=BCchi?= Date: Sun, 5 May 2024 01:17:54 +0000 Subject: [PATCH] taskswitcher: enhance gesture with flick speed tracking and orthogonal movement tracking This MR contains work to track the speed with which the taskswitcher gesture has occurred to allow different handling of a "fast flick" (eg: go to homescreen instead of taskswitcher). # Features The center piece of the overhaul is tracking recent velocity (averaged over the last ~50ms) of the gesture to determine behavior: - Swipe: A normal swipe is a "slow" finger move - Flick: A "fast" swipe The determining factor is the speed at the end of the gesture (= when lifting the finger up), anything before is irrelevant - Swipe up goes to task switcher - Flick up goes to homescreen directly instead of task switcher - this is suppressed when already on homescreen - it makes no sense there - Note: On Android that is actually mapped to "going to the first page of the home screen if not there already" - I don't care much for that and it can lead to nothing happening if you're already there, I think I prefer the consistency of always opening the task switcher vs a "quick way to get to the main (first) page of the homescreen" - especially since what that means depends a lot on the active home screen. - added orthogonal gesture movement tracking (2-axis gestures) - gesture properly follows finger movement in all directions now making it feel much more responsive and nice - diagonal flick for quick task switch - horizontal only movement (while holding, not releasing) triggers task scrub gesture for scrubbing through larger number of task in the switcher backlog - Complete refactor of state keeping logic, not just in relation to gestures - state keeper fully in one place in C++ except for - a few derived values that are very directly UI related which are kept in `TaskSwitcherHelpers.qml` - and directly touch gesture related state which are kept separate to maybe allow upstreaming the new touch border gesture handling to KWin in the future
done TODO items - [x] currentTaskIndex tracking is majorly broken - [x] old TaskSwitcherState.qml is still around, should be removed - [x] clamping minimum scale for some reason also clamps y position which makes the task not track the finger when moving past the "full activation" point - [x] switching between tasks when task switcher is open is super crap - [x] task switching when switcher is fully open works now, but flicks don't work - you have to fully move at least halfway to the next task for it to "snap" to that task when you let go, there is 0 momentum and I don't know why -> *Flickable is just broken and refuses to flick on touch input, same behavior on master with old implementation, so I reimplemented the same workaround from before* - [x] the gestures don't work until the notification drawer was invoked at least once after every screen geometry change (resolution change, screen rotation etc) - [x] keyboard shortcut for toggling task switcher is kinda hacky right now and feels awful when closing (some delay noticable, maybe to do with animations?) *NOTE: I disabled it for now because the hacky implementation interfered with/created bugs in other parts* - [x] task scrub mode has broken offsets/positions (can scroll past the end of the list, but not reach all elements) when opening task index is not 0 - [x] fast flick velocity threshold is a mostly arbitrary number and I think it also depends on pixel density - ideally we want a mm (or cm or whatever) per ms, not logical pixels per ms
# Design decisions I made during development: - Changed the y opening animation from OutBack (with a bounce) to a simple OutExpo (quickly move towards and then approach slowly) as the bouncy look and feel from the OutBack animation felt wrong to me in that place - To fit better with the new docked mode: - the task switcher now shows the screen geometry behind a task that is not fullscreened. This allows to consistently press (and see) the adjacent tasks which might not always be the case if it's a thin window on a landscape screen. - This is not great overall yet - actually it just checks the windows actual aspect ratio, so if it's smaller but same aspect ratio as screen it shows as if it's a full screen task - also it maximizes and centers the window thumbnail in the task switcher so it really isn't ideal, I just don't know what the behavior *should* ideally be. This change at least doesn't break the switcher when in docked mode. - on opening an app from task switcher it doesn't get force-fullscreened anymore - either it already is fullscreen (because fullscreen in docked mode or just not in docked mode) or it should just keep its geometry (this could be gated behind checking if docked mode is on or not, but I think it's fine without) # TODO before merge - [x] IMPORTANT: Because of regular crashing on interaction I've commented out any code that uses WindowFilterModel::data() which seems to be the cause for that. Since master also uses this and experiences these crashes (at least on both dev machines I tested), I don't think this MR necessarily needs to be held up for that issue (especially since it feels like it could be upstream). I removed this to have an easier time with testing my code without constant crashes, but before merging this would need to be put in again as it breaks: skipping windows with "skipSwitcher" flag set, and showing the correct task when opening the switcher in certain situations. - seems to be a bug with WindowFilterModel::data sometimes returning an invalid/null element? But not in C++, it just doesn't make its way to QML. - feels like it could be a timing thing (but maybe not?) when reverting the skipSwitcher filter rule on `tasksModel` *or* adding some lengthy debug console log in `TaskList.qml:minimizeAll()` before trying to access item.window properties it seems to be fine. When keeping skipSwitcher and having no lengthy debug output I get crashes rather reliably every ~3rd to 5th time invoking the task switcher. - While this does seem to be reproducible on master and thus "the same", if it is timing based it may be that my code triggers the error *more often* technically making it a regression (though also easier to track down?) - Interestingly I don't seem to be able to reproduce this on-device, my PinePhone doesn't have this behavior at all. Maybe a bug with nested KWin session? - [ ] task scrub mode may need to have a dynamic amount of items to scrub through based on display size/orientation (maybe keep distance for task scrubbing the same on portrait vs landscape to aid muscle memory)? or instead of using percentages/pixel distances use mm distance based on dpi? - [ ] is task scrub mode even something we want? Since I prefer the "quick switch gesture" only being able to switch between the two most recent windows (because of immediate reordering by recency), this leaves our task switcher in gesture mode with no quick "shortcut gesture" to switch to older tasks, task scrub mode makes this possible in IMO a nice way, but would love input there. - [ ] get rid of all code comments marked with `TODO!` (with an exclamation mark) - [x] One last pass on removing all superfluous console logs, includes and unused functions. I cleaned that up a lot already, but not completely (one std::cout is still left in on purpose until last minute because of a very rare gesture bug I saw twice and I'm not sure if my fix really got it) # Other - I've only ever tested on my laptop with touch screen (ie: effectively a large tablet form factor), if this merges and we get it on nightly I'll definitely try on the PinePhone, there I will closely scrutinize whether the undoYThreshold and task scrub mode number of tasks feels good to use on a small portrait mode display. This may lead to followup MRs to tweak those essentially arbitrarily chosen values. # TODOs for later MRs - [ ] consider moving velocity calculation/generic 2-dimensional gesture tracking to kwin if it's robust and generic enough for that to be useful on their end. (maybe do that later after it has proven itself for a while on our side) - [ ] (optional?) vibration feedback to tell if it goes to taskswitcher or homescreen? android has that (at least mine) with a tight vibration when task switcher is invoked vs homescree - [ ] flick to homescreen animation kinda looks bad - [ ] task sorting by recency - [ ] polish task scrub mode if we intend to keep it - [ ] task scrub mode can "run out of space" for the gesture and thus can only scrub through a maximum number of tasks (currently 8 either direction in my implementation), but we could add a timer if reaching the end that goes through tasks eg: one per second (acceleration?) to "unlock" the full task list - [ ] task scrub mode should have some visual cue when it's activated (eg: fully "opening" the switcher y position, maybe some extra UI with window thumbnails) - [ ] Positioning after screen geometry change (resize, rotation) of the task switcher is kinda broken (task may end off-screen etc) - fixed by interacting with it or re-opening and not a huge issue IMO - [ ] some bound for y position above the "fully opened position" (maybe make it more and more "reluctant" to follow the finger upwards the further up you go - I think that just makes it feel a bit nicer - [ ] make it behave better with docked mode (currently some animations look a bit scuffed) - [ ] make it behave better with multi screen (currently it's mirrored on all screens, we probably only want it on the screen that currently has focus?) - [ ] navbar buttons when not in gesture mode seem to have slightly different button icon sizes than the system wide navbar (is there no better way to add the navbar than to essentially reimplement it?) --- kwin/mobiletaskswitcher/CMakeLists.txt | 3 + kwin/mobiletaskswitcher/effecttouchborder.cpp | 71 ++++++ kwin/mobiletaskswitcher/effecttouchborder.h | 55 ++++ .../mobiletaskswitchereffect.cpp | 240 +++++++++++++++--- .../mobiletaskswitchereffect.h | 149 ++++++++++- .../mobiletaskswitcher/qml/FlickContainer.qml | 36 ++- kwin/mobiletaskswitcher/qml/Task.qml | 17 +- kwin/mobiletaskswitcher/qml/TaskList.qml | 45 ++-- kwin/mobiletaskswitcher/qml/TaskSwitcher.qml | 194 +++++++++++--- ...tcherState.qml => TaskSwitcherHelpers.qml} | 188 +++++++------- 10 files changed, 780 insertions(+), 218 deletions(-) create mode 100644 kwin/mobiletaskswitcher/effecttouchborder.cpp create mode 100644 kwin/mobiletaskswitcher/effecttouchborder.h rename kwin/mobiletaskswitcher/qml/{TaskSwitcherState.qml => TaskSwitcherHelpers.qml} (50%) diff --git a/kwin/mobiletaskswitcher/CMakeLists.txt b/kwin/mobiletaskswitcher/CMakeLists.txt index 6d797fe6..b5633b50 100644 --- a/kwin/mobiletaskswitcher/CMakeLists.txt +++ b/kwin/mobiletaskswitcher/CMakeLists.txt @@ -5,8 +5,11 @@ kcoreaddons_add_plugin(mobiletaskswitcher INSTALL_NAMESPACE "kwin/effects/plugin target_sources(mobiletaskswitcher PRIVATE main.cpp mobiletaskswitchereffect.cpp + effecttouchborder.cpp ) +target_compile_definitions(mobiletaskswitcher PRIVATE QT_QML_DEBUG) + target_link_libraries(mobiletaskswitcher KF6::ConfigGui KF6::GlobalAccel diff --git a/kwin/mobiletaskswitcher/effecttouchborder.cpp b/kwin/mobiletaskswitcher/effecttouchborder.cpp new file mode 100644 index 00000000..3c93eb68 --- /dev/null +++ b/kwin/mobiletaskswitcher/effecttouchborder.cpp @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2024 Luis Büchi +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "effecttouchborder.h" + +namespace KWin +{ + +EffectTouchBorderState::EffectTouchBorderState(Effect *parent) + : QObject(parent) + , m_activateAction{std::make_unique()} +{ + connect(m_activateAction.get(), &QAction::triggered, this, [this]() { + if (m_inProgress) { + setInProgress(false); + } + }); +} + +bool EffectTouchBorderState::inProgress() const +{ + return m_inProgress; +} + +void EffectTouchBorderState::setInProgress(bool inProgress) +{ + if (!effects->hasActiveFullScreenEffect() || effects->activeFullScreenEffect() == parent()) { + if (m_inProgress != inProgress) { + m_inProgress = inProgress; + Q_EMIT inProgressChanged(); + } + } +} + +EffectTouchBorder::EffectTouchBorder(EffectTouchBorderState *state) + : QObject(state) + , m_state(state) +{ +} + +EffectTouchBorder::~EffectTouchBorder() +{ + for (const ElectricBorder &border : std::as_const(m_touchBorderActivate)) { + effects->unregisterTouchBorder(border, m_state->activateAction()); + } +} + +void EffectTouchBorder::setBorders(const QList &touchActivateBorders) +{ + for (const ElectricBorder &border : std::as_const(m_touchBorderActivate)) { + effects->unregisterTouchBorder(border, m_state->activateAction()); + } + m_touchBorderActivate.clear(); + + for (const int &border : touchActivateBorders) { + m_touchBorderActivate.append(ElectricBorder(border)); + effects->registerRealtimeTouchBorder(ElectricBorder(border), + m_state->activateAction(), + [this](ElectricBorder border, const QPointF &deltaProgress, const Output *screen) { + m_state->setInProgress(true); + + if (border == ElectricTop || border == ElectricBottom) { + Q_EMIT touchPositionChanged(deltaProgress.y(), deltaProgress.x()); + } else { + Q_EMIT touchPositionChanged(deltaProgress.x(), deltaProgress.y()); + } + }); + } +} + +} // namespace KWin diff --git a/kwin/mobiletaskswitcher/effecttouchborder.h b/kwin/mobiletaskswitcher/effecttouchborder.h new file mode 100644 index 00000000..996358f8 --- /dev/null +++ b/kwin/mobiletaskswitcher/effecttouchborder.h @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2024 Luis Büchi +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +namespace KWin +{ + +class EffectTouchBorderState : public QObject +{ + Q_OBJECT + +public: + EffectTouchBorderState(Effect *parent); + + bool inProgress() const; + void setInProgress(bool inProgress); + + QAction *activateAction() const + { + return m_activateAction.get(); + } + +Q_SIGNALS: + void inProgressChanged(); + +private: + bool m_inProgress = false; + + std::unique_ptr m_activateAction; +}; + +class EffectTouchBorder : public QObject +{ + Q_OBJECT + +public: + EffectTouchBorder(EffectTouchBorderState *state); + ~EffectTouchBorder(); + + void setBorders(const QList &borders); + +Q_SIGNALS: + void touchPositionChanged(qreal primaryPosition, qreal orthogonalPosition); + +private: + QList m_touchBorderActivate; + EffectTouchBorderState *m_state; +}; + +} diff --git a/kwin/mobiletaskswitcher/mobiletaskswitchereffect.cpp b/kwin/mobiletaskswitcher/mobiletaskswitchereffect.cpp index fd08f5ec..ae66400d 100644 --- a/kwin/mobiletaskswitcher/mobiletaskswitchereffect.cpp +++ b/kwin/mobiletaskswitcher/mobiletaskswitchereffect.cpp @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: 2021 Vlad Zahorodnii // SPDX-FileCopyrightText: 2023 Devin Lin +// SPDX-FileCopyrightText: 2024 Luis Büchi // SPDX-License-Identifier: GPL-2.0-or-later #include "mobiletaskswitchereffect.h" @@ -10,29 +11,202 @@ #include #include #include +#include using namespace std::chrono_literals; namespace KWin { +MobileTaskSwitcherState::MobileTaskSwitcherState(EffectTouchBorderState *effectState) + : m_effectState{effectState} +{ + connect(m_effectState, &EffectTouchBorderState::inProgressChanged, this, &MobileTaskSwitcherState::gestureInProgressChanged); +} + +bool MobileTaskSwitcherState::gestureInProgress() const +{ + return m_effectState->inProgress(); +} + +void MobileTaskSwitcherState::setGestureInProgress(bool gestureInProgress) +{ + if (m_status == Status::Stopped) { + return; + } + m_effectState->setInProgress(gestureInProgress); +} + +bool MobileTaskSwitcherState::wasInActiveTask() const +{ + return m_wasInActiveTask; +} + +void MobileTaskSwitcherState::setWasInActiveTask(bool wasInActiveTask) +{ + if (m_wasInActiveTask != wasInActiveTask) { + m_wasInActiveTask = wasInActiveTask; + Q_EMIT wasInActiveTaskChanged(); + } +} + +void MobileTaskSwitcherState::updateWasInActiveTask(KWin::Window *window) +{ + bool newWasInActiveTask = false; + if (window) { + newWasInActiveTask = !window->isDesktop(); + } + setWasInActiveTask(newWasInActiveTask); +} + +qreal MobileTaskSwitcherState::touchXPosition() const +{ + return m_touchXPosition; +} + +qreal MobileTaskSwitcherState::touchYPosition() const +{ + return m_touchYPosition; +} + +qreal MobileTaskSwitcherState::xVelocity() const +{ + return m_xVelocity; +} + +qreal MobileTaskSwitcherState::yVelocity() const +{ + return m_yVelocity; +} + +qreal MobileTaskSwitcherState::totalSquaredVelocity() const +{ + return m_totalSquaredVelocity; +} + +qreal MobileTaskSwitcherState::flickVelocityThreshold() const +{ + return m_flickVelocityThreshold; +} + +void MobileTaskSwitcherState::setFlickVelocityThreshold(qreal flickVelocityThreshold) +{ + if (m_flickVelocityThreshold != flickVelocityThreshold) { + m_flickVelocityThreshold = flickVelocityThreshold; + Q_EMIT flickVelocityThresholdChanged(); + } +} + +qreal MobileTaskSwitcherState::xPosition() const +{ + return m_xPosition; +} + +void MobileTaskSwitcherState::setXPosition(qreal xPosition) +{ + if (m_xPosition != xPosition) { + m_xPosition = xPosition; + Q_EMIT xPositionChanged(); + } +} + +qreal MobileTaskSwitcherState::yPosition() const +{ + return m_yPosition; +} + +void MobileTaskSwitcherState::setYPosition(qreal yPosition) +{ + if (m_yPosition != yPosition) { + m_yPosition = yPosition; + Q_EMIT yPositionChanged(); + } +} + +void MobileTaskSwitcherState::setStatus(Status status) +{ + if (m_status != status) { + if (status == Status::Inactive) { + setYPosition(0); + } + m_status = status; + Q_EMIT statusChanged(); + } +} + +void MobileTaskSwitcherState::setCurrentTaskIndex(int newTaskIndex) +{ + if (m_currentTaskIndex != newTaskIndex) { + m_currentTaskIndex = newTaskIndex; + Q_EMIT currentTaskIndexChanged(); + } +} + +void MobileTaskSwitcherState::setInitialTaskIndex(int newTaskIndex) +{ + if (m_initialTaskIndex != newTaskIndex) { + m_initialTaskIndex = newTaskIndex; + Q_EMIT initialTaskIndexChanged(); + } +} + +void MobileTaskSwitcherState::calculateFilteredVelocity(qreal primaryDelta, qreal orthogonalDelta) +{ + static qreal prevPrimaryDelta = 0; + static qreal prevOrthogonalDelta = 0; + + qint64 frameTime = 0; + if (!m_frameTimer.isValid()) { + prevPrimaryDelta = 0; + prevOrthogonalDelta = 0; + m_frameTimer.start(); + return; + } + frameTime = m_frameTimer.restart(); + if (frameTime == 0) { + // Skip because otherwise we get NaN later on. Not sure why this triggers as often as it does + return; + } + + qreal framePrimaryDelta = primaryDelta - prevPrimaryDelta; + qreal frameOrthogonalDelta = orthogonalDelta - prevOrthogonalDelta; + prevPrimaryDelta = primaryDelta; + prevOrthogonalDelta = orthogonalDelta; + + // Implements an exponentially weighted moving average (EWMA) filter (= exponential smoothing) + // Smoothing factor is approximated each event to achieve a chosen filter time constant + qreal smoothingFactor = std::min(frameTime / (1000 * m_filterTimeConstant), 0.8); + m_yVelocity = m_yVelocity + smoothingFactor * (framePrimaryDelta / frameTime - m_yVelocity); + m_xVelocity = m_xVelocity + smoothingFactor * (frameOrthogonalDelta / frameTime - m_xVelocity); + m_totalSquaredVelocity = m_yVelocity * m_yVelocity + m_xVelocity * m_xVelocity; + Q_EMIT velocityChanged(); +} + +void MobileTaskSwitcherState::processTouchPositionChanged(qreal primaryDelta, qreal orthogonalDelta) +{ + calculateFilteredVelocity(primaryDelta, orthogonalDelta); + m_touchXPosition = orthogonalDelta; + m_touchYPosition = primaryDelta; + Q_EMIT touchPositionChanged(); +} + MobileTaskSwitcherEffect::MobileTaskSwitcherEffect() - : m_taskSwitcherState{new EffectTogglableState(this)} - , m_border{new EffectTogglableTouchBorder{m_taskSwitcherState}} + : m_effectState{new EffectTouchBorderState(this)} + , m_taskSwitcherState{new MobileTaskSwitcherState(m_effectState)} + , m_border{new EffectTouchBorder{m_effectState}} + , m_toggleAction{std::make_unique()} , m_shutdownTimer{new QTimer{this}} { - auto gesture = new EffectTogglableGesture{m_taskSwitcherState}; - gesture->addTouchscreenSwipeGesture(SwipeDirection::Up, 3); + const char *uri = "org.kde.private.mobileshell.taskswitcher"; + qmlRegisterSingletonType(uri, 1, 0, "TaskSwitcherState", [this](QQmlEngine *, QJSEngine *) -> QObject * { + return this->m_taskSwitcherState; + }); - connect(m_taskSwitcherState, &EffectTogglableState::inProgressChanged, this, &MobileTaskSwitcherEffect::gestureInProgressChanged); - connect(m_taskSwitcherState, &EffectTogglableState::partialActivationFactorChanged, this, &MobileTaskSwitcherEffect::partialActivationFactorChanged); - connect(m_taskSwitcherState, &EffectTogglableState::statusChanged, this, [this](EffectTogglableState::Status status) { - if (status == EffectTogglableState::Status::Activating || status == EffectTogglableState::Status::Active) { - setRunning(true); - setDBusState(true); - } - if (status == EffectTogglableState::Status::Inactive) { - deactivate(true); + connect(m_border, &EffectTouchBorder::touchPositionChanged, m_taskSwitcherState, &MobileTaskSwitcherState::processTouchPositionChanged); + + connect(m_taskSwitcherState, &MobileTaskSwitcherState::gestureInProgressChanged, this, [this]() { + if (m_taskSwitcherState->gestureInProgress()) { + invokeEffect(); } }); @@ -43,11 +217,11 @@ MobileTaskSwitcherEffect::MobileTaskSwitcherEffect() // toggle action const QKeySequence defaultToggleShortcut = Qt::META | Qt::Key_C; - auto toggleAction = m_taskSwitcherState->toggleAction(); - toggleAction->setObjectName(QStringLiteral("Mobile Task Switcher")); - toggleAction->setText(i18n("Toggle Mobile Task Switcher")); - KGlobalAccel::self()->setDefaultShortcut(toggleAction, {defaultToggleShortcut}); - KGlobalAccel::self()->setShortcut(toggleAction, {defaultToggleShortcut}); + m_toggleAction.get()->setObjectName(QStringLiteral("Mobile Task Switcher")); + m_toggleAction.get()->setText(i18n("Toggle Mobile Task Switcher")); + KGlobalAccel::self()->setDefaultShortcut(m_toggleAction.get(), {defaultToggleShortcut}); + KGlobalAccel::self()->setShortcut(m_toggleAction.get(), {defaultToggleShortcut}); + connect(m_toggleAction.get(), &QAction::triggered, this, &MobileTaskSwitcherEffect::toggle); connect(effects, &EffectsHandler::screenAboutToLock, this, &MobileTaskSwitcherEffect::realDeactivate); @@ -102,7 +276,8 @@ void MobileTaskSwitcherEffect::activate() return; } - m_taskSwitcherState->activate(); + m_effectState->setInProgress(false); + invokeEffect(); } void MobileTaskSwitcherEffect::deactivate(bool deactivateInstantly) @@ -118,11 +293,10 @@ void MobileTaskSwitcherEffect::deactivate(bool deactivateInstantly) void MobileTaskSwitcherEffect::realDeactivate() { - m_taskSwitcherState->deactivate(); - if (m_taskSwitcherState->status() == EffectTogglableState::Status::Inactive) { - setRunning(false); - setDBusState(false); - } + m_effectState->setInProgress(false); + m_taskSwitcherState->setStatus(MobileTaskSwitcherState::Status::Inactive); + setRunning(false); + setDBusState(false); } void MobileTaskSwitcherEffect::quickDeactivate() @@ -143,16 +317,6 @@ void MobileTaskSwitcherEffect::setAnimationDuration(int duration) } } -bool MobileTaskSwitcherEffect::gestureInProgress() const -{ - return m_taskSwitcherState->inProgress(); -} - -qreal MobileTaskSwitcherEffect::partialActivationFactor() const -{ - return m_taskSwitcherState->partialActivationFactor(); -} - void MobileTaskSwitcherEffect::setDBusState(bool active) { QDBusMessage request = QDBusMessage::createMethodCall(QStringLiteral("org.kde.plasmashell"), @@ -164,4 +328,12 @@ void MobileTaskSwitcherEffect::setDBusState(bool active) // this does not block, so it won't necessarily be called before the method returns QDBusConnection::sessionBus().send(request); } + +void MobileTaskSwitcherEffect::invokeEffect() +{ + m_taskSwitcherState->setInitialTaskIndex( + m_taskSwitcherState->currentTaskIndex()); // TODO! this is only until the crashing bug is fixed and recency sorting is in + setRunning(true); + setDBusState(true); +} } diff --git a/kwin/mobiletaskswitcher/mobiletaskswitchereffect.h b/kwin/mobiletaskswitcher/mobiletaskswitchereffect.h index f50a2a18..1f33420d 100644 --- a/kwin/mobiletaskswitcher/mobiletaskswitchereffect.h +++ b/kwin/mobiletaskswitcher/mobiletaskswitchereffect.h @@ -1,31 +1,157 @@ // SPDX-FileCopyrightText: 2021 Vlad Zahorodnii // SPDX-FileCopyrightText: 2023 Devin Lin +// SPDX-FileCopyrightText: 2024 Luis Büchi // SPDX-License-Identifier: GPL-2.0-or-later #pragma once +#include #include #include -#include -#include +#include #include #include +#include #include #include #include #include +#include "effecttouchborder.h" + namespace KWin { +class MobileTaskSwitcherState : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool wasInActiveTask READ wasInActiveTask WRITE setWasInActiveTask NOTIFY wasInActiveTaskChanged) + Q_PROPERTY(int currentTaskIndex READ currentTaskIndex WRITE setCurrentTaskIndex NOTIFY currentTaskIndexChanged) + Q_PROPERTY(int initialTaskIndex READ initialTaskIndex WRITE setInitialTaskIndex NOTIFY initialTaskIndexChanged) + + Q_PROPERTY(qreal touchXPosition READ touchXPosition NOTIFY touchPositionChanged) + Q_PROPERTY(qreal touchYPosition READ touchYPosition NOTIFY touchPositionChanged) + + Q_PROPERTY(qreal xVelocity READ xVelocity NOTIFY velocityChanged) + Q_PROPERTY(qreal yVelocity READ yVelocity NOTIFY velocityChanged) + Q_PROPERTY(qreal totalSquaredVelocity READ totalSquaredVelocity NOTIFY velocityChanged) + Q_PROPERTY(qreal flickVelocityThreshold READ flickVelocityThreshold NOTIFY flickVelocityThresholdChanged) + + Q_PROPERTY(qreal xPosition READ xPosition WRITE setXPosition NOTIFY xPositionChanged) + Q_PROPERTY(qreal yPosition READ yPosition WRITE setYPosition NOTIFY yPositionChanged) + + Q_PROPERTY(bool gestureInProgress READ gestureInProgress NOTIFY gestureInProgressChanged) + Q_PROPERTY(Status status READ status WRITE setStatus NOTIFY statusChanged) + +public: + enum class Status { + // TODO! I could (should?) re-add the activating and deactivating states again to match EffectTogglableState. could help with/tie into + // currentlyBeingOpened and currentlyBeingClosed + Inactive, // Task switcher is not fully showing + Active, // Task switcher is active and gesture not in progress anymore + Stopped // When is this the case? + }; + Q_ENUM(Status) + + MobileTaskSwitcherState(EffectTouchBorderState *effectState); + + bool gestureInProgress() const; + void setGestureInProgress(bool gestureInProgress); + bool wasInActiveTask() const; + void setWasInActiveTask(bool wasInActiveTask); + Q_INVOKABLE void updateWasInActiveTask(KWin::Window *window); + + qreal touchXPosition() const; + qreal touchYPosition() const; + qreal xVelocity() const; + qreal yVelocity() const; + qreal totalSquaredVelocity() const; + qreal flickVelocityThreshold() const; + void setFlickVelocityThreshold(qreal flickVelocityThreshold); + + qreal xPosition() const; + void setXPosition(qreal positionX); + qreal yPosition() const; + void setYPosition(qreal positionY); + + void setStatus(Status status); + Status status() const + { + return m_status; + } + + void setCurrentTaskIndex(int newTaskIndex); + int currentTaskIndex() const + { + return m_currentTaskIndex; + } + + void setInitialTaskIndex(int newTaskIndex); + int initialTaskIndex() const + { + return m_initialTaskIndex; + } + +public Q_SLOTS: + void processTouchPositionChanged(qreal primaryPosition, qreal orthogonalPosition); + +Q_SIGNALS: + void activated(); + void deactivated(); + + void gestureInProgressChanged(); + void statusChanged(); + + void wasInActiveTaskChanged(); + + void currentTaskIndexChanged(); + void initialTaskIndexChanged(); + + void touchPositionChanged(); + + void velocityChanged(); + void flickVelocityThresholdChanged(); + + void xPositionChanged(); + void yPositionChanged(); + +private: + Status m_status = Status::Inactive; + EffectTouchBorderState *m_effectState; + bool m_gestureInProgress = false; + + int m_currentTaskIndex; + int m_initialTaskIndex; + + void clearVelocityFilter(); + void calculateFilteredVelocity(qreal primaryPosition, qreal orthogonalPosition); + + // velocities in (logical) pixels/msec + QElapsedTimer m_frameTimer; + qreal m_flickVelocityThreshold = 0.5 * 0.5; // squared because total velocity is kept as a square + qreal m_filterTimeConstant = 0.03; // time constant of velocity filter + + qreal m_touchXPosition; + qreal m_touchYPosition; + qreal m_xVelocity = 0; + qreal m_yVelocity = 0; + // Using the square of velocity for the total (2-axis) because we just need it + // for one threshold comparison and we skip having to calculate the square root + qreal m_totalSquaredVelocity; + + // Positions of the task switcher effect itself + qreal m_xPosition = 0; + qreal m_yPosition = 0; + + bool m_wasInActiveTask; +}; + class MobileTaskSwitcherEffect : public QuickSceneEffect { Q_OBJECT - Q_PROPERTY(qreal partialActivationFactor READ partialActivationFactor NOTIFY partialActivationFactorChanged) - Q_PROPERTY(bool gestureInProgress READ gestureInProgress NOTIFY gestureInProgressChanged) public: enum class Status { Inactive, Activating, Deactivating, Active }; @@ -35,10 +161,6 @@ public: int animationDuration() const; void setAnimationDuration(int duration); - bool gestureInProgress() const; - - qreal partialActivationFactor() const; - int requestedEffectChainPosition() const override; bool borderActivated(ElectricBorder border) override; void reconfigure(ReconfigureFlags flags) override; @@ -56,15 +178,16 @@ public Q_SLOTS: Q_SIGNALS: void animationDurationChanged(); void gestureInProgressChanged(); - void partialActivationFactorChanged(); private: - EffectTogglableState *const m_taskSwitcherState; - EffectTogglableTouchBorder *const m_border; + void invokeEffect(); + + EffectTouchBorderState *const m_effectState; + MobileTaskSwitcherState *const m_taskSwitcherState; + EffectTouchBorder *const m_border; QList m_borderActivate = {ElectricBorder::ElectricBottom}; - QAction *m_realtimeToggleAction = nullptr; - QAction *m_toggleAction = nullptr; + std::unique_ptr m_toggleAction; QList m_toggleShortcut; QTimer *m_shutdownTimer; diff --git a/kwin/mobiletaskswitcher/qml/FlickContainer.qml b/kwin/mobiletaskswitcher/qml/FlickContainer.qml index 25123b82..47bbd522 100644 --- a/kwin/mobiletaskswitcher/qml/FlickContainer.qml +++ b/kwin/mobiletaskswitcher/qml/FlickContainer.qml @@ -4,44 +4,58 @@ import QtQuick Flickable { + // TODO flickable is busted, it refuses to actually do any flicks with touch input, only works with mouse + // we work around this somewhat by snapping to the nearest task in which direction it was moving when letting go + // no matter how far away we are (and no matter how fast we go) id: root required property var taskSwitcherState + required property var taskSwitcherHelpers // we use flickable solely for capturing flicks, not positioning elements - contentWidth: width * tasksCount + // the horizontal distance we can swipe in one flick + contentWidth: (taskSwitcherHelpers.taskWidth + taskSwitcherHelpers.taskSpacing) * tasksCount contentHeight: height contentX: startContentX - readonly property real startContentX: 0 + + readonly property real startContentX: (taskSwitcherHelpers.taskWidth + taskSwitcherHelpers.taskSpacing) * tasksCount + property bool movingRight: false // TODO needed for flickable not flicking workaround // update position from horizontal flickable movement property real oldContentX onContentXChanged: { - taskSwitcherState.xPosition += contentX - oldContentX; + if (moving) { + // TODO whenever flicking actually works this should probably be swapped with + // a minimum velocity after which it should snap to the nearest task + taskSwitcherState.xPosition += contentX - oldContentX; + } + movingRight = contentX < oldContentX; oldContentX = contentX; } - onMovementStarted: taskSwitcherState.cancelAnimations(); + onMovementStarted: { + taskSwitcherHelpers.cancelAnimations(); + } onMovementEnded: { + taskSwitcherHelpers.snapToNearestTaskWorkaround(movingRight); resetPosition(); - taskSwitcherState.updateState(); } onFlickStarted: { root.cancelFlick(); } onFlickEnded: { - resetPosition(); - taskSwitcherState.updateState(); + // taskSwitcherHelpers.snapToNearestTaskWorkaround(movingRight); + // resetPosition(); } onDraggingChanged: { - if (!dragging) { - resetPosition(); - taskSwitcherState.updateState(); + if (dragging) { + taskSwitcherHelpers.cancelAnimations(); } else { - taskSwitcherState.cancelAnimations(); + taskSwitcherHelpers.scrollingTasks = false; + resetPosition(); } } diff --git a/kwin/mobiletaskswitcher/qml/Task.qml b/kwin/mobiletaskswitcher/qml/Task.qml index 83257b77..eb9bd80e 100644 --- a/kwin/mobiletaskswitcher/qml/Task.qml +++ b/kwin/mobiletaskswitcher/qml/Task.qml @@ -43,9 +43,7 @@ Item { } function activateApp() { - taskSwitcherState.wasInActiveTask = false; - taskSwitcher.activateWindow(model.index, delegate.window); - delegate.window.setMaximize(true, true); + taskSwitcherHelpers.openApp(model.index, delegate.window); } function minimizeApp() { @@ -192,16 +190,17 @@ Item { // app preview Rectangle { id: appView - Layout.preferredWidth: delegate.previewWidth - Layout.preferredHeight: delegate.previewHeight - Layout.maximumWidth: delegate.previewWidth - Layout.maximumHeight: delegate.previewHeight + Layout.preferredWidth: taskSwitcherHelpers.previewWidth + Layout.preferredHeight: taskSwitcherHelpers.previewHeight + Layout.maximumWidth: taskSwitcherHelpers.previewWidth + Layout.maximumHeight: taskSwitcherHelpers.previewHeight - color: "transparent" + radius: Kirigami.Units.largeSpacing + color: Qt.rgba(0, 0, 0, 0.2) clip: true // scale animation on press - property real zoomScale: control.pressed ? 0.9 : 1 + property real zoomScale: control.pressed ? 0.95 : 1 Behavior on zoomScale { NumberAnimation { duration: 200 diff --git a/kwin/mobiletaskswitcher/qml/TaskList.qml b/kwin/mobiletaskswitcher/qml/TaskList.qml index a1a9f7c5..7efd4011 100644 --- a/kwin/mobiletaskswitcher/qml/TaskList.qml +++ b/kwin/mobiletaskswitcher/qml/TaskList.qml @@ -17,7 +17,8 @@ MouseArea { required property real shellBottomMargin required property var taskSwitcher - readonly property var taskSwitcherState: taskSwitcher.taskSwitcherState + readonly property var taskSwitcherState: taskSwitcher.state + readonly property var taskSwitcherHelpers: taskSwitcher.taskSwitcherHelpers property int taskInteractingCount: 0 @@ -27,41 +28,39 @@ MouseArea { let footerHeight = shellBottomMargin; let diff = headerHeight - footerHeight; - let baseY = (taskSwitcher.height / 2) - (taskSwitcherState.taskHeight / 2) - (taskSwitcherState.taskHeaderHeight / 2) + let baseY = (taskSwitcher.height / 2) - (taskSwitcherHelpers.taskHeight / 2) - (taskSwitcherHelpers.taskHeaderHeight / 2); - return baseY + diff / 2 - shellTopMargin; + return baseY + diff / 2 - shellTopMargin - trackFingerYOffset; } + readonly property real trackFingerYOffset: taskSwitcherHelpers.isScaleClamped ? taskSwitcherState.yPosition - taskSwitcherHelpers.openedYPosition : 0 function getTaskAt(index) { return repeater.itemAt(index); } function closeAll() { - for (var i = 0; i < repeater.count; i++) { + for (let i = 0; i < repeater.count; i++) { repeater.itemAt(i).closeApp(); } } function minimizeAll() { - for (var i = 0; i < repeater.count; i++) { + for (let i = 0; i < repeater.count; i++) { let item = repeater.itemAt(i); - // update property + // minimize window if (!item.window.minimized) { - taskSwitcherState.wasInActiveTask = true; + item.minimizeApp(); } - - // minimize window immediately if it shows up - item.minimizeApp(); } } function jumpToFirstVisibleWindow() { - for (var i = 0; i < repeater.count; i++) { + for (let i = 0; i < repeater.count; i++) { let item = repeater.itemAt(i); if (!item.window.minimized) { - taskSwitcherState.goToTaskIndex(i); + taskSwitcherHelpers.goToTaskIndex(i); break; } } @@ -70,8 +69,8 @@ MouseArea { transform: Scale { origin.x: root.width / 2 origin.y: root.height / 2 - xScale: taskSwitcherState.currentScale - yScale: taskSwitcherState.currentScale + xScale: taskSwitcherHelpers.currentScale + yScale: taskSwitcherHelpers.currentScale } onClicked: { @@ -82,7 +81,7 @@ MouseArea { onPressedChanged: { if (!taskSwitcherState.currentlyBeingOpened && pressed) { // ensure animations aren't running when finger is pressed - taskSwitcherState.cancelAnimations(); + taskSwitcherHelpers.cancelAnimations(); } } @@ -91,14 +90,14 @@ MouseArea { model: taskSwitcher.tasksModel // left margin from root edge such that the task is centered - readonly property real leftMargin: (root.width / 2) - (taskSwitcherState.taskWidth / 2) + readonly property real leftMargin: (root.width / 2) - (taskSwitcherHelpers.taskWidth / 2) delegate: Task { id: task readonly property int currentIndex: model.index // this is the x-position with respect to the list - property real listX: taskSwitcherState.xPositionFromTaskIndex(currentIndex); + property real listX: taskSwitcherHelpers.xPositionFromTaskIndex(currentIndex); Behavior on listX { NumberAnimation { duration: Kirigami.Units.longDuration @@ -114,11 +113,11 @@ MouseArea { z: taskSwitcherState.currentTaskIndex === currentIndex ? 1 : 0 // only show header once task switcher is opened - showHeader: !taskSwitcherState.currentlyBeingOpened + showHeader: !taskSwitcherState.gestureInProgress && !taskSwitcherHelpers.currentlyBeingClosed && !taskSwitcherHelpers.isInTaskScrubMode // darken effect as task gets away from the centre of the screen darken: { - const distFromCentreProgress = Math.abs(x - repeater.leftMargin) / taskSwitcherState.taskWidth; + const distFromCentreProgress = Math.abs(x - repeater.leftMargin) / taskSwitcherHelpers.taskWidth; const upperBoundAdjust = Math.min(0.5, distFromCentreProgress) - 0.2; return Math.max(0, upperBoundAdjust); } @@ -129,10 +128,10 @@ MouseArea { taskInteractingCount = Math.max(0, taskInteractingCount + offset); } - width: taskSwitcherState.taskWidth - height: taskSwitcherState.taskHeight - previewWidth: taskSwitcherState.previewWidth - previewHeight: taskSwitcherState.previewHeight + width: taskSwitcherHelpers.taskWidth + height: taskSwitcherHelpers.taskHeight + previewWidth: taskSwitcherHelpers.previewWidth + previewHeight: taskSwitcherHelpers.previewHeight taskSwitcher: root.taskSwitcher } diff --git a/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml b/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml index ca9550e6..4c97605a 100644 --- a/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml +++ b/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml @@ -11,11 +11,13 @@ 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. */ @@ -24,6 +26,7 @@ FocusScope { 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 @@ -31,8 +34,9 @@ FocusScope { readonly property real leftMargin: 0 readonly property real rightMargin: MobileShell.Constants.navigationPanelOnSide(width, height) ? MobileShell.Constants.navigationPanelThickness : 0 - property var taskSwitcherState: TaskSwitcherState { + property var taskSwitcherHelpers: TaskSwitcherHelpers { taskSwitcher: root + stateClass: TaskSwitcherData.TaskSwitcherState } KWinComponents.WindowModel { @@ -57,10 +61,9 @@ FocusScope { property var tasksModel: KSortFilterProxyModel { sourceModel: baseTasksModel - filterRoleName: 'skipSwitcher' + filterRoleName: 'window' filterRowCallback: function(source_row, source_parent) { const window = sourceModel.data(sourceModel.index(source_row, 0, source_parent), Qt.DisplayRole); - // ensure apps marked to skip the task switcher are skipped (ex. xwaylandvideobridge) return !window.skipSwitcher; } } @@ -72,9 +75,9 @@ FocusScope { onTasksCountChanged: { if (tasksCount === 0 && oldTasksCount !== 0) { hide(); - } else if (tasksCount < oldTasksCount && taskSwitcherState.currentTaskIndex >= tasksCount - 1) { + } else if (tasksCount < oldTasksCount && state.currentTaskIndex >= tasksCount) { // if the user is on the last task, and it is closed, scroll left - taskSwitcherState.animateGoToTaskIndex(tasksCount - 1, Kirigami.Units.longDuration); + taskSwitcherHelpers.animateGoToTaskIndex(tasksCount - 1, Kirigami.Units.longDuration); } oldTasksCount = tasksCount; @@ -83,12 +86,21 @@ FocusScope { Keys.onEscapePressed: hide(); Component.onCompleted: { - taskList.jumpToFirstVisibleWindow(); + 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 panel (if this is a button press, not gesture) - if (!root.effect.gestureInProgress) { - taskSwitcherState.open(); + // fully open the switcher (if this is a button press, not gesture) + if (!root.state.gestureInProgress) { + taskSwitcherHelpers.open(); } } @@ -105,24 +117,138 @@ FocusScope { root.effect.deactivate(false); } - // scroll to delegate index, and activate it - function activateWindow(index, window) { - KWinComponents.Workspace.activeWindow = window; - taskSwitcherState.openApp(index, window); - } - Connections { - target: root.effect + target: root.state - function onPartialActivationFactorChanged() { - taskSwitcherState.yPosition = taskSwitcherState.openedYPosition * root.effect.partialActivationFactor; + // 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 onGestureInProgressChanged() { - if (!root.effect.gestureInProgress) { - taskSwitcherState.updateState(); + 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, tasksCount - 1; + if (newIndex < tasksCount) { + // switch only if flick doesn't go over end of list + shouldSwitch = true; + } + } else if (state.xVelocity < 0 && state.wasInActiveTask) { + newIndex = Math.max(state.currentTaskIndex - 1, 0); + 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 @@ -141,10 +267,10 @@ FocusScope { opacity: container.opacity color: { // animate background colour only if we are *not* opening from the homescreen - if (taskSwitcherState.wasInActiveTask || !taskSwitcherState.currentlyBeingOpened) { + 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, taskSwitcherState.yPosition / taskSwitcherState.openedYPosition)); + return Qt.rgba(0, 0, 0, 0.6 * Math.min(1, state.yPosition / state.openedYPosition)); } } } @@ -183,8 +309,8 @@ FocusScope { if (taskList.count === 0) { root.hide(); } else { - const currentIndex = taskSwitcherState.currentTaskIndex; - taskSwitcherState.openApp(taskSwitcherState.currentTaskIndex, taskList.getTaskAt(currentIndex).window); + const currentIndex = state.currentTaskIndex; + taskSwitcherHelpers.openApp(state.currentTaskIndex, taskList.getTaskAt(currentIndex).window); } } } @@ -204,7 +330,7 @@ FocusScope { iconSizeFactor: 0.75 onTriggered: { - taskList.getTaskAt(taskSwitcherState.currentTaskIndex).closeApp(); + taskList.getTaskAt(state.currentTaskIndex).closeApp(); } } @@ -278,7 +404,7 @@ FocusScope { ColumnLayout { id: placeholder spacing: Kirigami.Units.gridUnit - opacity: (root.tasksCount === 0 && !taskSwitcherState.currentlyBeingClosed) ? 0.9 : 0 + opacity: (root.tasksCount === 0 && !taskSwitcherHelpers.currentlyBeingClosed) ? 0.9 : 0 Behavior on opacity { NumberAnimation { duration: 500 } } anchors.centerIn: parent @@ -309,7 +435,8 @@ FocusScope { id: flickable anchors.fill: parent - taskSwitcherState: root.taskSwitcherState + taskSwitcherState: root.state + taskSwitcherHelpers: root.taskSwitcherHelpers // don't allow FlickContainer to steal from swiping on tasks interactive: taskList.taskInteractingCount === 0 @@ -323,10 +450,11 @@ FocusScope { opacity: { // animate opacity only if we are *not* opening from the homescreen - if (taskSwitcherState.wasInActiveTask || !taskSwitcherState.currentlyBeingOpened) { + // 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, taskSwitcherState.yPosition / taskSwitcherState.openedYPosition); + return Math.min(1, state.yPosition / state.openedYPosition); } } @@ -337,18 +465,18 @@ FocusScope { PlasmaComponents.ToolButton { id: closeAllButton property bool closeRequested: false - visible: root.tasksCount !== 0 + visible: root.tasksCount !== 0 && !taskSwitcherHelpers.isInTaskScrubMode anchors { bottom: parent.bottom - bottomMargin: taskList.taskY / 2 + bottomMargin: (taskList.taskY + taskList.trackFingerYOffset) / 2 horizontalCenter: parent.horizontalCenter } Kirigami.Theme.colorSet: Kirigami.Theme.Complementary Kirigami.Theme.inherit: false - opacity: (taskSwitcherState.currentlyBeingOpened || taskSwitcherState.currentlyBeingClosed) ? 0.0 : 1.0 + opacity: (taskSwitcherHelpers.currentlyBeingClosed) ? 0.0 : 1.0 Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } } icon.name: "edit-clear-history" diff --git a/kwin/mobiletaskswitcher/qml/TaskSwitcherState.qml b/kwin/mobiletaskswitcher/qml/TaskSwitcherHelpers.qml similarity index 50% rename from kwin/mobiletaskswitcher/qml/TaskSwitcherState.qml rename to kwin/mobiletaskswitcher/qml/TaskSwitcherHelpers.qml index 7998e344..f5501d0d 100644 --- a/kwin/mobiletaskswitcher/qml/TaskSwitcherState.qml +++ b/kwin/mobiletaskswitcher/qml/TaskSwitcherHelpers.qml @@ -16,59 +16,29 @@ QtObject { // TaskSwitcher item component // We assume that the taskSwitcher the size of the entire screen. required property var taskSwitcher + property var state: taskSwitcher.state + required property var stateClass - // ~~ positioning ~~ - - // Position of the list view: - // - // xPosition: - // We start at 0, which is the position at which the first task in the task switcher is centered on the screen. - // Decreasing xPosition results in the task switcher moving forward (to the second task, third task, etc), being the layout direction Right to Left. - // - // yPosition: - // 0 - Start of swipe up gesture, if window was showing, the thumbnail is the size of it - // Increasing yPosition results in the task switcher moving up (and thumbnails shrinking) - property real xPosition: 0 - property real yPosition: 0 - // direction of the movement - property bool movingRight: false - property bool movingUp: false + readonly property bool gestureMovingRight: state.xVelocity > 0 + readonly property bool gestureMovingUp: state.yVelocity < 0 - // used for calculating movement direction - property real oldXPosition: 0 - property real oldYPosition: 0 - onXPositionChanged: { - movingRight = xPosition < oldXPosition; - oldXPosition = xPosition; - } - onYPositionChanged: { - movingUp = yPosition > oldYPosition; - oldYPosition = yPosition; - } + readonly property bool currentlyBeingOpened: state.gestureInProgress || openAnim.running + readonly property bool currentlyBeingClosed: closeAnim.running || openAppAnim.running // yPosition when the task switcher is completely open readonly property real openedYPosition: (taskSwitcher.height - taskHeight) / 2 + // yPosition threshold below which opening the task switcher should be undone and returned to the previously active task + readonly property real undoYThreshold: openedYPosition / 3 + // ~~ active state ~~ - // whether the user was in an active task before the task switcher was opened - property bool wasInActiveTask: false - - // whether we are in a swipe up gesture to open the task switcher - property bool currentlyBeingOpened: true // assume that task switcher is loaded on open - - // whether the task switcher is being closed: an animation is running - property bool currentlyBeingClosed: false - // whether we are in a swipe left/right gesture to walk through tasks property bool scrollingTasks: false - - readonly property int currentTaskIndex: { - let candidateIndex = Math.round(-xPosition / (taskSpacing + taskWidth)); - return Math.max(0, Math.min(taskSwitcher.tasksCount - 1, candidateIndex)); - } + // TODO! we're not using this anymore and when I hooked it up again it just produced broken-looking visuals + // I don't even know what this was *supposed* to do in the past, so I don't know how to fix it. I'd just remove it altogether // ~~ measurement constants ~~ @@ -79,6 +49,7 @@ QtObject { // 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 @@ -106,15 +77,19 @@ QtObject { // 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) * (yPosition / openedYPosition); - let finalScale = Math.max(0, Math.min(maxScale, maxScale - subtract)); + let subtract = (maxScale - 1) * Math.min(root.state.yPosition / openedYPosition, 1); + let finalScale = Math.min(maxScale, maxScale - subtract); // animate scale only if we are *not* opening from the homescreen - if ((wasInActiveTask || !currentlyBeingOpened) && !scrollingTasks) { + if ((root.state.wasInActiveTask || !root.state.gestureInProgress) && !scrollingTasks) { return finalScale; } return scrollingTasks ? maxScale : 1; } + 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 ~~ @@ -127,6 +102,26 @@ QtObject { xAnim.stop(); } + function getTaskIndexFromWindow(window) { + 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() { + 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() { + root.state.currentTaskIndex = getTaskIndexFromXPosition(); + } + function open() { openAnim.restart(); } @@ -137,9 +132,8 @@ QtObject { function openApp(index, window) { // cancel any opening animations ongoing - currentlyBeingOpened = false; cancelAnimations(); - + animateGoToTaskIndex(index, Kirigami.Units.shortDuration); openAppAnim.restart(); KWinComponents.Workspace.activeWindow = window @@ -152,47 +146,59 @@ QtObject { // instantly go to the task index function goToTaskIndex(index) { - xPosition = xPositionFromTaskIndex(index); + root.state.xPosition = xPositionFromTaskIndex(index); } // go to the task index, animated - function animateGoToTaskIndex(index, duration) { + function animateGoToTaskIndex(index, duration = Kirigami.Units.longDuration * 2) { xAnim.duration = duration; xAnim.to = xPositionFromTaskIndex(index); xAnim.restart(); } - // called after a user finishes an interaction (ex. lets go of the screen) - function updateState() { - cancelAnimations(); - - // update vertical state - if ((movingUp || root.yPosition >= openedYPosition) && !scrollingTasks) { - // open task switcher and stay - openAnim.restart(); - } else { - // close task switcher and return to app - closeAnim.restart(); - } - - // update horizontal state - let duration = Kirigami.Units.longDuration * 2; - if (currentlyBeingOpened) { - animateGoToTaskIndex(currentTaskIndex, duration); - } else { - let currentTaskIndexPosition = xPositionFromTaskIndex(currentTaskIndex); - if (xPosition > currentTaskIndexPosition) { - if (movingRight) { - animateGoToTaskIndex(currentTaskIndex, duration); - } else { - animateGoToTaskIndex(Math.max(0, currentTaskIndex - 1), duration); - } + function getNearestTaskIndex() { + 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 { - if (movingRight) { - animateGoToTaskIndex(Math.min(taskSwitcher.tasksCount - 1, currentTaskIndex + 1), duration); - } else { - animateGoToTaskIndex(currentTaskIndex, duration); - } + // 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() { + 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) { + 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); } } } @@ -200,52 +206,44 @@ QtObject { // ~~ property animators ~~ property var xAnim: NumberAnimation { - target: root + target: root.state property: "xPosition" easing.type: Easing.OutBack } property var openAnim: NumberAnimation { - target: root + target: root.state property: "yPosition" to: openedYPosition - duration: 300 - easing.type: Easing.OutBack + duration: 250 + easing.type: Easing.OutExpo onFinished: { - root.currentlyBeingOpened = false; + root.state.status = stateClass.Active; } } property var closeAnim: NumberAnimation { - target: root + target: root.state property: "yPosition" to: 0 duration: Kirigami.Units.longDuration easing.type: Easing.InOutQuad - - onStarted: root.currentlyBeingClosed = true - onFinished: { - root.currentlyBeingClosed = false; - root.currentlyBeingOpened = false; + root.state.status = stateClass.Inactive; scrollingTasks = false; taskSwitcher.instantHide(); } } property var openAppAnim: NumberAnimation { - target: root + target: root.state property: "yPosition" to: 0 duration: 300 easing.type: Easing.OutQuint - - onStarted: root.currentlyBeingClosed = true - onFinished: { - root.currentlyBeingClosed = false; - root.currentlyBeingOpened = false; + root.state.status = stateClass.Inactive; taskSwitcher.instantHide(); } }