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


<details><summary>done TODO items</summary>

- [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

</details>

# 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?)
This commit is contained in:
Luis Büchi 2024-05-05 01:17:54 +00:00 committed by Devin Lin
parent eefc04d0c7
commit 8f8bcfd8be
10 changed files with 780 additions and 218 deletions

View file

@ -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

View file

@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2024 Luis Büchi <luis.buechi@server23.cc>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "effecttouchborder.h"
namespace KWin
{
EffectTouchBorderState::EffectTouchBorderState(Effect *parent)
: QObject(parent)
, m_activateAction{std::make_unique<QAction>()}
{
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<int> &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

View file

@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2024 Luis Büchi <luis.buechi@server23.cc>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QAction>
#include <effect/effect.h>
#include <effect/effecthandler.h>
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<QAction> m_activateAction;
};
class EffectTouchBorder : public QObject
{
Q_OBJECT
public:
EffectTouchBorder(EffectTouchBorderState *state);
~EffectTouchBorder();
void setBorders(const QList<int> &borders);
Q_SIGNALS:
void touchPositionChanged(qreal primaryPosition, qreal orthogonalPosition);
private:
QList<ElectricBorder> m_touchBorderActivate;
EffectTouchBorderState *m_state;
};
}

View file

@ -1,5 +1,6 @@
// SPDX-FileCopyrightText: 2021 Vlad Zahorodnii <vlad.zahorodnii@kde.org>
// SPDX-FileCopyrightText: 2023 Devin Lin <devin@kde.org>
// SPDX-FileCopyrightText: 2024 Luis Büchi <luis.buechi@server23.cc>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "mobiletaskswitchereffect.h"
@ -10,29 +11,202 @@
#include <QKeyEvent>
#include <QMetaObject>
#include <QQuickItem>
#include <window.h>
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<QAction>()}
, 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<MobileTaskSwitcherState>(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);
}
}

View file

@ -1,31 +1,157 @@
// SPDX-FileCopyrightText: 2021 Vlad Zahorodnii <vlad.zahorodnii@kde.org>
// SPDX-FileCopyrightText: 2023 Devin Lin <devin@kde.org>
// SPDX-FileCopyrightText: 2024 Luis Büchi <luis.buechi@server23.cc>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <effect/effect.h>
#include <effect/effecthandler.h>
#include <effect/quickeffect.h>
#include <effect/effect.h>
#include <effect/effecttogglablestate.h>
#include <window.h>
#include <span>
#include <QAction>
#include <QElapsedTimer>
#include <QKeySequence>
#include <QTimer>
#include <KGlobalAccel>
#include <KLocalizedString>
#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<int> m_borderActivate = {ElectricBorder::ElectricBottom};
QAction *m_realtimeToggleAction = nullptr;
QAction *m_toggleAction = nullptr;
std::unique_ptr<QAction> m_toggleAction;
QList<QKeySequence> m_toggleShortcut;
QTimer *m_shutdownTimer;

View file

@ -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();
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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"

View file

@ -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();
}
}