diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index 309bb757b..c267da810 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -17,6 +17,8 @@ import java.io.File */ object DesktopCatalog { private const val TAG = "DesktopCatalog" + /** Advance this when the Plasma root's required package set changes. */ + internal const val PLASMA_MOBILE_PACKAGE_REVISION = "archlinuxarm-plasma-apps-v2" const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json" class Entry( @@ -66,7 +68,7 @@ object DesktopCatalog { fun plasmaMobileInstalled(context: Context): Boolean { val root = LinuxRuntime.plasmaRootDir(context) return LinuxRuntime.isPlasmaRootCurrent(context) && - plasmaMobileMarker(context).isFile && + FileUtils.readString(plasmaMobileMarker(context))?.trim() == PLASMA_MOBILE_PACKAGE_REVISION && File(root, "usr/bin/kwin_wayland").isFile && File(root, "usr/bin/plasmashell").isFile && File(root, "usr/share/plasma/shells/org.kde.plasma.mobileshell").isDirectory diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/PlasmaMobileInstaller.kt b/app/src/main/java/com/droiddeck/launcher/runtime/PlasmaMobileInstaller.kt index 55e766434..ff9880914 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/PlasmaMobileInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/PlasmaMobileInstaller.kt @@ -9,6 +9,8 @@ import java.nio.file.Files /** Installs the optional Plasma Mobile shell into its isolated Arch Linux ARM package root. */ object PlasmaMobileInstaller { + private val packages = listOf("plasma-mobile", "plasma-settings", "angelfish") + @Synchronized fun install(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? { if (!LinuxRuntime.isInstalled(context)) return "The Linux runtime is not installed" @@ -45,7 +47,12 @@ object PlasmaMobileInstaller { // unowned files from its base image; the Steam root remains untouched. "/bin/sh", "-c", "/usr/bin/pacman-key --init && /usr/bin/pacman-key --populate archlinuxarm && " + - "exec /usr/bin/pacman -Syyu --needed --noconfirm --overwrite '*' plasma-mobile", + "/usr/bin/pacman -Syyu --needed --noconfirm --overwrite '*' ${packages.joinToString(" ")} && " + + // Keep Firefox and Discover out of the Plasma-only clone even if the base root + // already installed them; the original desktop root remains unchanged. + "for excluded in firefox discover; do " + + "if /usr/bin/pacman -Qq \"\$excluded\" >/dev/null 2>&1; then " + + "/usr/bin/pacman -Rns --noconfirm \"\$excluded\" || exit; fi; done", ) val command = LinuxRuntime.commandForRoot( context, root, sessionRoot, runtimeDir, null, null, guest, true, @@ -89,7 +96,7 @@ object PlasmaMobileInstaller { return "pacman exited with status $status" + if (detail.isNotEmpty()) ": $detail" else "" } val packageMarker = DesktopCatalog.plasmaMobileMarker(context) - FileUtils.writeString(packageMarker, "archlinuxarm\n") + FileUtils.writeString(packageMarker, "${DesktopCatalog.PLASMA_MOBILE_PACKAGE_REVISION}\n") if (!packageMarker.isFile) return "Plasma Mobile packages installed, but DroidDeck could not save the installation marker" if (!DesktopCatalog.plasmaMobileInstalled(context)) { packageMarker.delete() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index a9dd1b520..3e39ca93e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -60,6 +60,12 @@ object SessionFiles { val desktop = arrayOf( "usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop", "usr/local/bin/droiddeck-plasma-mobile" to "usr/local/bin/droiddeck-plasma-mobile", + // Plasma Mobile 6.7.5 backports: populate the Folio app catalog without a Wayland + // connection handle and reset the native mobile task switcher's gesture state. + "usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" to "usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so", + "usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so" to "usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so", + "usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml" to "usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml", + "usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml" to "usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml", // Games and emulators from the menu, full screen in a gamescope of their own. "usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu", "usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu", diff --git a/tools/build_local.sh b/tools/build_local.sh index 6555dd408..445d8b850 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -148,6 +148,8 @@ docker run --rm --platform linux/amd64 \ test -f "$d/usr/local/bin/bannerlator-proton-extra" ' +"${repo_root}/tools/plasma-mobile/build.sh" + github_repo=${DROIDDECK_GITHUB_REPOSITORY:-} if [[ -z "${github_repo}" ]]; then origin_url=$(git -C "${repo_root}" remote get-url origin) diff --git a/tools/linuxfs/desktop/droiddeck-plasma-mobile b/tools/linuxfs/desktop/droiddeck-plasma-mobile index a608588aa..320fd5247 100644 --- a/tools/linuxfs/desktop/droiddeck-plasma-mobile +++ b/tools/linuxfs/desktop/droiddeck-plasma-mobile @@ -81,6 +81,21 @@ dbus-run-session -- env XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" plasma_log="$plasma_l else echo "== plasma-mobile-envmanager is missing" fi + # This guest has no interactive login from which the user can set a Linux password. The Plasma + # Mobile PIN keypad authenticates that account password, so an idle/lock event can strand a + # device user at a screen they cannot unlock. Keep session locking disabled for this embedded + # desktop and let Android own the device lock instead. + lock_config="${XDG_CONFIG_HOME:-$HOME/.config}/kscreenlockerrc" + mkdir -p "$(dirname "$lock_config")" + if command -v kwriteconfig6 >/dev/null 2>&1; then + kwriteconfig6 --file "$lock_config" --group Daemon --key Autolock false + kwriteconfig6 --file "$lock_config" --group Daemon --key LockOnResume false + kwriteconfig6 --file "$lock_config" --group Daemon --key RequirePassword false + kwriteconfig6 --file "$lock_config" --group Daemon --key Timeout 0 + echo "== disabled Plasma screen locking in $lock_config" + else + echo "== kwriteconfig6 is missing; could not disable Plasma screen locking" + fi if command -v kbuildsycoca6 >/dev/null 2>&1; then echo "== building KDE service cache" QT_QPA_PLATFORM=offscreen kbuildsycoca6 --noincremental >>"$plasma_log" 2>&1 @@ -95,13 +110,28 @@ dbus-run-session -- env XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" plasma_log="$plasma_l else echo "== kbuildsycoca6 is missing" fi + effect_config="${XDG_CONFIG_HOME:-$HOME/.config}/kwinrc" + if command -v kwriteconfig6 >/dev/null 2>&1; then + # The overview control in Plasma Mobile invokes this KWin effect. Keep it explicitly enabled + # so the patched state/gesture initialization is active in the embedded session. + kwriteconfig6 --file "$effect_config" --group Plugins --key mobiletaskswitcherEnabled true + fi + if command -v kreadconfig6 >/dev/null 2>&1; then + effect_setting=$(kreadconfig6 --file "$effect_config" \ + --group Plugins --key mobiletaskswitcherEnabled 2>&1 || true) + echo "== mobile task-switcher setting in $effect_config: ${effect_setting:-unset}" + else + echo "== kreadconfig6 is missing; cannot inspect the mobile task-switcher setting" + fi echo "== starting nested KWin and Plasma Mobile on wayland-0 (${width}x${height}), socket wayland-1" - KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 QT_FORCE_STDERR_LOGGING=1 QT_LOGGING_RULES="kwin*.debug=true" kwin_wayland \ + # The mobile task switcher queries Workspace::activities() while filtering tasks. + # Keep KActivities initialized; --no-kactivities makes the overview crash on activation. + KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 kwin_wayland \ --xwayland \ --wayland-display=wayland-0 \ --socket=wayland-1 \ --width="$width" --height="$height" --fullscreen \ - --no-lockscreen --no-global-shortcuts --no-kactivities \ + --no-lockscreen \ --exit-with-session "plasmashell -p org.kde.plasma.mobileshell --replace" \ >>"$plasma_log" 2>&1 & kwin_pid=$! diff --git a/tools/plasma-mobile/Dockerfile b/tools/plasma-mobile/Dockerfile new file mode 100644 index 000000000..0684073a0 --- /dev/null +++ b/tools/plasma-mobile/Dockerfile @@ -0,0 +1,13 @@ +FROM menci/archlinuxarm:base-devel + +# Pacman 7's Landlock sandbox cannot start in this Docker kernel. Package builds still run as the +# container user; disable only the unavailable sandbox paths for the dependency-install layer. +RUN sed -i \ + 's/^[[:space:]]*#DownloadUser.*/DownloadUser =/; s/^#DisableSandboxFilesystem/DisableSandboxFilesystem/; s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/' \ + /etc/pacman.conf \ + && pacman -Syu --noconfirm --needed \ + base-devel cmake extra-cmake-modules git ninja pkgconf \ + plasma-mobile plasma-wayland-protocols vulkan-headers \ + && pacman -Scc --noconfirm + +WORKDIR /src diff --git a/tools/plasma-mobile/README.md b/tools/plasma-mobile/README.md new file mode 100644 index 000000000..e425182a2 --- /dev/null +++ b/tools/plasma-mobile/README.md @@ -0,0 +1,17 @@ +# Plasma Mobile 6.7.5 runtime backports + +The Thor root installs Plasma Mobile 6.7.5. The app build backports KDE's +[`Use effect caching` change](https://invent.kde.org/plasma/plasma-mobile/-/commit/73c5b4fd2815dc5d845c09cf578f60a9af3e708b) +to fix the mobile task switcher state, and removes Folio's requirement for a +shared KWayland client connection before it queries installed applications. + +`tools/build_local.sh` stages the checked-in ARM64 modules under the generated +`app/src/main/assets/linuxfs` tree. To rebuild those modules, run +`tools/plasma-mobile/rebuild.sh`; it builds the affected targets in the Arch +Linux ARM environment described by `Dockerfile` and refreshes `prebuilt/`. +This keeps the changes isolated to the Plasma runtime; the LXQt/Steam root is +not modified. The module sources and patches target tag `v6.7.5`. + +The app-catalog patch currently logs whether Folio receives a KWayland +connection and how many KService entries it finds. Those diagnostics are for +Thor dogfooding and can be removed after the empty-drawer cause is confirmed. diff --git a/tools/plasma-mobile/build-in-container.sh b/tools/plasma-mobile/build-in-container.sh new file mode 100755 index 000000000..1531c7cc0 --- /dev/null +++ b/tools/plasma-mobile/build-in-container.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +plasma_version=6.7.5 +source_dir="${TMPDIR:-/tmp}/plasma-mobile-${plasma_version}" +build_dir="${TMPDIR:-/tmp}/plasma-mobile-build-${plasma_version}" +asset_root="${repo_root}/app/src/main/assets/linuxfs" + +installed_version=$(pacman -Q plasma-mobile | awk '{print $2}') +case "${installed_version}" in + "${plasma_version}"-*) ;; + *) + echo "Expected plasma-mobile ${plasma_version}, found ${installed_version}." >&2 + exit 1 + ;; +esac + +rm -rf -- "${source_dir}" "${build_dir}" +git clone --quiet --depth 1 --branch "v${plasma_version}" \ + https://invent.kde.org/plasma/plasma-mobile.git "${source_dir}" +git -C "${source_dir}" apply --check \ + "${repo_root}/tools/plasma-mobile/patches/0001-mobile-task-switcher.patch" \ + "${repo_root}/tools/plasma-mobile/patches/0002-folio-app-model.patch" +git -C "${source_dir}" apply \ + "${repo_root}/tools/plasma-mobile/patches/0001-mobile-task-switcher.patch" \ + "${repo_root}/tools/plasma-mobile/patches/0002-folio-app-model.patch" + +cmake -S "${source_dir}" -B "${build_dir}" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF +cmake --build "${build_dir}" --parallel "${DROIDDECK_PLASMA_BUILD_JOBS:-4}" \ + --target mobiletaskswitcherplugin org.kde.plasma.mobile.homescreen.folio + +install -Dm644 "${build_dir}/bin/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" \ + "${asset_root}/usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" +install -Dm644 "${build_dir}/kwin/mobiletaskswitcher/plugin/libmobiletaskswitcherplugin.so" \ + "${asset_root}/usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so" +install -Dm644 "${source_dir}/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml" \ + "${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml" +install -Dm644 "${source_dir}/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml" \ + "${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml" +strip --strip-unneeded \ + "${asset_root}/usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" \ + "${asset_root}/usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so" + +echo "Built Plasma Mobile ${plasma_version} app-catalog and task-switcher backports." diff --git a/tools/plasma-mobile/build.sh b/tools/plasma-mobile/build.sh new file mode 100755 index 000000000..f6d09db8b --- /dev/null +++ b/tools/plasma-mobile/build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +source_dir="${repo_root}/tools/plasma-mobile/prebuilt" +asset_root="${repo_root}/app/src/main/assets/linuxfs" + +install -Dm644 "${source_dir}/org.kde.plasma.mobile.homescreen.folio.so" \ + "${asset_root}/usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" +install -Dm644 "${source_dir}/libmobiletaskswitcherplugin.so" \ + "${asset_root}/usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so" +install -Dm644 "${source_dir}/FlickContainer.qml" \ + "${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml" +install -Dm644 "${source_dir}/TaskSwitcher.qml" \ + "${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml" + +echo "Staged the Plasma Mobile 6.7.5 ARM64 backports. Run tools/plasma-mobile/rebuild.sh to rebuild them." diff --git a/tools/plasma-mobile/patches/0001-mobile-task-switcher.patch b/tools/plasma-mobile/patches/0001-mobile-task-switcher.patch new file mode 100644 index 000000000..3897ae1d8 --- /dev/null +++ b/tools/plasma-mobile/patches/0001-mobile-task-switcher.patch @@ -0,0 +1,278 @@ +diff --git a/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml b/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml +index 8f89960..8a80991 100644 +--- a/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml ++++ b/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml +@@ -25,10 +25,16 @@ Flickable { + + readonly property real startContentX: (taskSwitcherHelpers.taskWidth + taskSwitcherHelpers.taskSpacing) * tasksCount + property bool movingRight: false // TODO needed for flickable not flicking workaround ++ property bool resettingPosition: false + + // update position from horizontal flickable movement + property real oldContentX + onContentXChanged: { ++ if (resettingPosition) { ++ oldContentX = contentX; ++ return; ++ } ++ + // disable if animations are running to prevent bugs + if (taskSwitcherHelpers.currentlyBeingClosed) {return} + +@@ -43,6 +49,8 @@ Flickable { + + onMovementStarted: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} ++ oldContentX = contentX; ++ movingRight = false; + taskSwitcherHelpers.cancelAnimations(); + } + onMovementEnded: { +@@ -63,6 +71,8 @@ Flickable { + onDraggingChanged: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} + if (dragging) { ++ oldContentX = contentX; ++ movingRight = false; + taskSwitcherHelpers.cancelAnimations(); + } else { + resetPosition(); +@@ -70,7 +80,14 @@ Flickable { + } + + function resetPosition(): void { ++ resettingPosition = true; ++ cancelFlick(); + oldContentX = startContentX; + contentX = startContentX; ++ movingRight = false; ++ Qt.callLater(() => { ++ oldContentX = contentX; ++ resettingPosition = false; ++ }); + } + } +diff --git a/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml b/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml +index e614639..ca3908e 100644 +--- a/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml ++++ b/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml +@@ -85,11 +85,12 @@ FocusScope { + + Keys.onEscapePressed: hide(); + +- Component.onCompleted: { +- initialSetup(); +- } +- + function initialSetup(): void { ++ closeAnim.stop(); ++ container.opacity = 1; ++ closeAllButton.closeRequested = false; ++ oldTasksCount = tasksCount; ++ + taskSwitcherHelpers.cancelAnimations(); + state.updateWasInActiveTask(KWinComponents.Workspace.activeWindow); + +@@ -102,12 +103,14 @@ FocusScope { + taskSwitcherHelpers.hasVibrated = false; + + taskSwitcherHelpers.closingFactor = 1; ++ taskSwitcherHelpers.closingScalingFactor = 1; + + taskSwitcherHelpers.taskSwitchCanLaunch = false; + taskSwitchCanLaunchTimer.restart() + + taskList.taskOffsetEasing = Easing.InOutQuart; + taskList.homeTouchPositionX = 0; ++ flickable.resetPosition(); + + backgroundColorOpacityAn.enabled = false; + backgroundColorOpacity = state.wasInActiveTask ? 1 : 0; +@@ -161,6 +164,11 @@ FocusScope { + Connections { + target: root.state + ++ function onActivated(): void { ++ root.tasksModel.refresh(); ++ root.initialSetup(); ++ } ++ + // task scrub mode allows scrubbing through a number of tasks with a mostly horizontal motion + function taskScrubMode(): void { + taskList.setTaskOffsetValue(0, false, Easing.OutQuart); +@@ -827,4 +835,3 @@ FocusScope { + } + } + } +- +diff --git a/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.cpp b/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.cpp +index 5c2f376..e8baaff 100644 +--- a/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.cpp ++++ b/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.cpp +@@ -35,8 +35,11 @@ void MobileTaskSwitcherState::init(KWin::QuickSceneEffect *parent) + m_border = new EffectTouchBorder{m_effectState}; + m_taskModel = new TaskModel{parent}; + m_effect = parent; ++ m_effect->setViewCachingEnabled(true); + + // Connect signals ++ connect(m_effect, &QuickSceneEffect::activated, this, &MobileTaskSwitcherState::activated); ++ connect(m_effect, &QuickSceneEffect::deactivated, this, &MobileTaskSwitcherState::deactivated); + connect(this, &MobileTaskSwitcherState::gestureEnabledChanged, this, &MobileTaskSwitcherState::refreshBorders); + connect(m_border, &EffectTouchBorder::touchPositionChanged, this, &MobileTaskSwitcherState::processTouchPositionChanged); + connect(this, &MobileTaskSwitcherState::gestureInProgressChanged, this, [this]() { +@@ -223,13 +226,10 @@ void MobileTaskSwitcherState::restartDoubleClickTimer() + + 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_previousPrimaryDelta = primaryDelta; ++ m_previousOrthogonalDelta = orthogonalDelta; + m_frameTimer.start(); + return; + } +@@ -239,10 +239,10 @@ void MobileTaskSwitcherState::calculateFilteredVelocity(qreal primaryDelta, qrea + return; + } + +- qreal framePrimaryDelta = primaryDelta - prevPrimaryDelta; +- qreal frameOrthogonalDelta = orthogonalDelta - prevOrthogonalDelta; +- prevPrimaryDelta = primaryDelta; +- prevOrthogonalDelta = orthogonalDelta; ++ qreal framePrimaryDelta = primaryDelta - m_previousPrimaryDelta; ++ qreal frameOrthogonalDelta = orthogonalDelta - m_previousOrthogonalDelta; ++ m_previousPrimaryDelta = primaryDelta; ++ m_previousOrthogonalDelta = orthogonalDelta; + + // Implements an exponentially weighted moving average (EWMA) filter (= exponential smoothing) + // Smoothing factor is approximated each event to achieve a chosen filter time constant +@@ -253,6 +253,24 @@ void MobileTaskSwitcherState::calculateFilteredVelocity(qreal primaryDelta, qrea + Q_EMIT velocityChanged(); + } + ++void MobileTaskSwitcherState::clearVelocityFilter() ++{ ++ m_frameTimer.invalidate(); ++ m_previousPrimaryDelta = 0; ++ m_previousOrthogonalDelta = 0; ++ m_xVelocity = 0; ++ m_yVelocity = 0; ++ m_totalSquaredVelocity = 0; ++ Q_EMIT velocityChanged(); ++} ++ ++void MobileTaskSwitcherState::resetGestureState() ++{ ++ m_touchXPosition = 0; ++ m_touchYPosition = 0; ++ clearVelocityFilter(); ++} ++ + void MobileTaskSwitcherState::processTouchPositionChanged(qreal primaryDelta, qreal orthogonalDelta) + { + calculateFilteredVelocity(primaryDelta, orthogonalDelta); +@@ -289,6 +307,7 @@ void MobileTaskSwitcherState::activate() + return; + } + ++ resetGestureState(); + m_effectState->setInProgress(false); + invokeEffect(); + } +@@ -339,6 +358,8 @@ void MobileTaskSwitcherState::setDBusState(bool active) + + void MobileTaskSwitcherState::invokeEffect() + { ++ m_shutdownTimer->stop(); ++ resetGestureState(); + setInitialTaskIndex(currentTaskIndex()); // TODO! this is only until the crashing bug is fixed and recency sorting is in + m_effect->setRunning(true); + setDBusState(true); +diff --git a/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.h b/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.h +index d85f1cc..0aae2a7 100644 +--- a/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.h ++++ b/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.h +@@ -144,6 +144,7 @@ private Q_SLOTS: + + private: + void invokeEffect(); ++ void resetGestureState(); + + bool m_gestureEnabled{false}; + EffectTouchBorderState *m_effectState{nullptr}; +@@ -154,8 +155,8 @@ private: + Status m_status = Status::Inactive; + bool m_gestureInProgress = false; + +- int m_currentTaskIndex; +- int m_initialTaskIndex; ++ int m_currentTaskIndex = 0; ++ int m_initialTaskIndex = 0; + + void clearVelocityFilter(); + void calculateFilteredVelocity(qreal primaryPosition, qreal orthogonalPosition); +@@ -163,22 +164,24 @@ private: + + // velocities in (logical) pixels/msec + QElapsedTimer m_frameTimer; ++ qreal m_previousPrimaryDelta = 0; ++ qreal m_previousOrthogonalDelta = 0; + 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_touchXPosition = 0; ++ qreal m_touchYPosition = 0; + 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; ++ qreal m_totalSquaredVelocity = 0; + + // Positions of the task switcher effect itself + qreal m_xPosition = 0; + qreal m_yPosition = 0; + +- bool m_wasInActiveTask; ++ bool m_wasInActiveTask = false; + + QElapsedTimer *m_doubleClickTimer; + qint64 getDoubleClickInterval() const +diff --git a/kwin/mobiletaskswitcher/plugin/taskfiltermodel.cpp b/kwin/mobiletaskswitcher/plugin/taskfiltermodel.cpp +index 59e1f48..6e04c86 100644 +--- a/kwin/mobiletaskswitcher/plugin/taskfiltermodel.cpp ++++ b/kwin/mobiletaskswitcher/plugin/taskfiltermodel.cpp +@@ -43,6 +43,12 @@ void TaskFilterModel::setWindowModel(TaskModel *taskModel) + sort(0); + } + ++void TaskFilterModel::refresh() ++{ ++ invalidateFilter(); ++ sort(0); ++} ++ + QString TaskFilterModel::screenName() const + { + return m_output ? m_output->name() : QString(); +diff --git a/kwin/mobiletaskswitcher/plugin/taskfiltermodel.h b/kwin/mobiletaskswitcher/plugin/taskfiltermodel.h +index 87244cc..ce3688a 100644 +--- a/kwin/mobiletaskswitcher/plugin/taskfiltermodel.h ++++ b/kwin/mobiletaskswitcher/plugin/taskfiltermodel.h +@@ -30,6 +30,8 @@ public: + TaskModel *windowModel() const; + void setWindowModel(KWin::TaskModel *taskModel); + ++ Q_INVOKABLE void refresh(); ++ + QString screenName() const; + void setScreenName(const QString &screenName); + diff --git a/tools/plasma-mobile/patches/0002-folio-app-model.patch b/tools/plasma-mobile/patches/0002-folio-app-model.patch new file mode 100644 index 000000000..90b7ec3ae --- /dev/null +++ b/tools/plasma-mobile/patches/0002-folio-app-model.patch @@ -0,0 +1,60 @@ +diff --git a/containments/homescreens/folio/applicationlistmodel.cpp b/containments/homescreens/folio/applicationlistmodel.cpp +--- a/containments/homescreens/folio/applicationlistmodel.cpp ++++ b/containments/homescreens/folio/applicationlistmodel.cpp +@@ -35,9 +35,9 @@ ApplicationListModel::ApplicationListModel(HomeScreen *parent) + + // initialize wayland window checking + KWayland::Client::ConnectionThread *connection = KWayland::Client::ConnectionThread::fromApplication(this); +- if (!connection) { +- return; +- } +- ++ qDebug() << "Folio app model Wayland client connection:" << (connection != nullptr); ++ // Listing and launching desktop entries does not need a Wayland client connection. In an ++ // embedded compositor session KWayland may not expose its shared connection to this model; ++ // still populate the catalog so the drawer is usable. + load(); + } +@@ -93,6 +93,7 @@ void ApplicationListModel::load() + } + + const KService::List currentApps = queryApplications(); ++ qDebug() << "Folio application service query returned" << currentApps.size() << "entries"; + QList toInsert; + + for (const KService::Ptr &service : currentApps) { +diff --git a/containments/homescreens/folio/qml/AppDrawer.qml b/containments/homescreens/folio/qml/AppDrawer.qml +index a389b20..5b1840a 100644 +--- a/containments/homescreens/folio/qml/AppDrawer.qml ++++ b/containments/homescreens/folio/qml/AppDrawer.qml +@@ -87,7 +87,7 @@ Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom +- opacity: 0 // we display with the opacity gradient below ++ opacity: 1 // Render app tiles directly with Qt's software Quick backend. + headerHeight: root.headerHeight + + // Keyboard navigation +@@ -104,10 +104,5 @@ Item { + } + } + +- // Opacity gradient at grid edges +- MobileShell.FlickableOpacityGradient { +- anchors.fill: appDrawerGrid +- flickable: appDrawerGrid +- } + } + } +diff --git a/containments/homescreens/folio/qml/AppDrawerGrid.qml b/containments/homescreens/folio/qml/AppDrawerGrid.qml +--- a/containments/homescreens/folio/qml/AppDrawerGrid.qml ++++ b/containments/homescreens/folio/qml/AppDrawerGrid.qml +@@ -29,6 +29,5 @@ MobileShell.GridView { + + cacheBuffer: cellHeight * 20 + reuseItems: true +- layer.enabled: true + + keyNavigationEnabled: true + highlightMoveDuration: 0 diff --git a/tools/plasma-mobile/prebuilt/FlickContainer.qml b/tools/plasma-mobile/prebuilt/FlickContainer.qml new file mode 100644 index 000000000..8a8099182 --- /dev/null +++ b/tools/plasma-mobile/prebuilt/FlickContainer.qml @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2021-2023 Devin Lin +// SPDX-FileCopyrightText: 2025 Luis Büchi +// SPDX-License-Identifier: LGPL-2.0-or-later + +pragma ComponentBehavior: Bound + +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 + required property int tasksCount + + // we use flickable solely for capturing flicks, not positioning elements + // the horizontal distance we can swipe in one flick + contentWidth: (taskSwitcherHelpers.taskWidth + taskSwitcherHelpers.taskSpacing) * tasksCount + contentHeight: height + contentX: startContentX + + + readonly property real startContentX: (taskSwitcherHelpers.taskWidth + taskSwitcherHelpers.taskSpacing) * tasksCount + property bool movingRight: false // TODO needed for flickable not flicking workaround + property bool resettingPosition: false + + // update position from horizontal flickable movement + property real oldContentX + onContentXChanged: { + if (resettingPosition) { + oldContentX = contentX; + return; + } + + // disable if animations are running to prevent bugs + if (taskSwitcherHelpers.currentlyBeingClosed) {return} + + 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: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} + oldContentX = contentX; + movingRight = false; + taskSwitcherHelpers.cancelAnimations(); + } + onMovementEnded: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} + taskSwitcherHelpers.snapToNearestTaskWorkaround(movingRight); + resetPosition(); + } + + onFlickStarted: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} + root.cancelFlick(); + } + onFlickEnded: { + // taskSwitcherHelpers.snapToNearestTaskWorkaround(movingRight); + // resetPosition(); + } + + onDraggingChanged: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} + if (dragging) { + oldContentX = contentX; + movingRight = false; + taskSwitcherHelpers.cancelAnimations(); + } else { + resetPosition(); + } + } + + function resetPosition(): void { + resettingPosition = true; + cancelFlick(); + oldContentX = startContentX; + contentX = startContentX; + movingRight = false; + Qt.callLater(() => { + oldContentX = contentX; + resettingPosition = false; + }); + } +} diff --git a/tools/plasma-mobile/prebuilt/TaskSwitcher.qml b/tools/plasma-mobile/prebuilt/TaskSwitcher.qml new file mode 100644 index 000000000..ca3908eff --- /dev/null +++ b/tools/plasma-mobile/prebuilt/TaskSwitcher.qml @@ -0,0 +1,837 @@ +// SPDX-FileCopyrightText: 2015 Marco Martin +// SPDX-FileCopyrightText: 2021-2024 Devin Lin +// SPDX-FileCopyrightText: 2024-2025 Luis Büchi +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components 3.0 as PlasmaComponents +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.private.mobileshell.state as MobileShellState +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.taskswitcherplugin as TaskSwitcherPlugin + +import org.kde.kwin 3.0 as KWinComponents +import org.kde.kwin.private.effects 1.0 +import org.kde.kitemmodels + + +/** + * Component that provides a task switcher. + */ +FocusScope { + id: root + focus: true + + property TaskSwitcherPlugin.MobileTaskSwitcherState state + readonly property QtObject effect: KWinComponents.SceneView.effect + readonly property QtObject targetScreen: KWinComponents.SceneView.screen + + readonly property real navBottomMargin: MobileShell.Constants.navigationPanelOnSide(width, height) ? 0 : MobileShell.Constants.navigationPanelThickness + readonly property real navRightMargin: MobileShell.Constants.navigationPanelOnSide(width, height) ? MobileShell.Constants.navigationPanelThickness : 0 + readonly property real topMargin: ShellSettings.Settings.autoHidePanelsEnabled ? 0 : MobileShell.Constants.topPanelHeight + readonly property real bottomMargin: ShellSettings.Settings.autoHidePanelsEnabled ? 0 : navBottomMargin + readonly property real leftMargin: 0 + readonly property real rightMargin: ShellSettings.Settings.autoHidePanelsEnabled ? 0 : navRightMargin + + property var taskSwitcherHelpers: TaskSwitcherHelpers { + taskSwitcher: root + taskList: taskList + } + + MobileShell.HapticsEffect { + id: haptics + } + + property TaskSwitcherPlugin.TaskFilterModel tasksModel: TaskSwitcherPlugin.TaskFilterModel { + screenName: root.targetScreen.name + windowModel: root.state.taskModel + } + + readonly property int tasksCount: taskList.count + + // keep track of task list events + property int oldTasksCount: tasksCount + onTasksCountChanged: { + // we need to subtract 1 from the current index when the closed task index is smaller + // this is because this part of the list has been shifted down by 1 when the closed task was removed. + if (taskSwitcherHelpers.lastClosedTask < state.currentTaskIndex) { + state.currentTaskIndex -= 1; + + // animated at the same speed as the task x position in the TaskList so that the task appears not to move from the perspective of the user. + taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex, Kirigami.Units.longDuration, Easing.InOutQuad); + taskSwitcherHelpers.lastClosedTask = -1; + } + + if (tasksCount === 0 && oldTasksCount !== 0) { + hide(); + } else if (tasksCount < oldTasksCount) { + if (state.currentTaskIndex < 0) { + // if the user is on the first task, and it is closed, scroll right + taskSwitcherHelpers.animateGoToTaskIndex(0, Kirigami.Units.longDuration); + } else if (state.currentTaskIndex >= tasksCount) { + // if the user is on the last task, and it is closed, scroll left + taskSwitcherHelpers.animateGoToTaskIndex(tasksCount - 1, Kirigami.Units.longDuration); + } + } + + oldTasksCount = tasksCount; + } + + Keys.onEscapePressed: hide(); + + function initialSetup(): void { + closeAnim.stop(); + container.opacity = 1; + closeAllButton.closeRequested = false; + oldTasksCount = tasksCount; + + taskSwitcherHelpers.cancelAnimations(); + state.updateWasInActiveTask(KWinComponents.Workspace.activeWindow); + + // ensure the task drawer is not opened and reset values to defaults + taskSwitcherHelpers.reachedHeightThreshold = false; + taskSwitcherHelpers.gestureState = TaskSwitcherHelpers.GestureStates.Undecided; + taskSwitcherHelpers.isInTaskScrubMode = false; + taskSwitcherHelpers.inLastFrame = false; + + taskSwitcherHelpers.hasVibrated = false; + + taskSwitcherHelpers.closingFactor = 1; + taskSwitcherHelpers.closingScalingFactor = 1; + + taskSwitcherHelpers.taskSwitchCanLaunch = false; + taskSwitchCanLaunchTimer.restart() + + taskList.taskOffsetEasing = Easing.InOutQuart; + taskList.homeTouchPositionX = 0; + flickable.resetPosition(); + + backgroundColorOpacityAn.enabled = false; + backgroundColorOpacity = state.wasInActiveTask ? 1 : 0; + backgroundColorOpacityAn.enabled = true; + + // reset the offset to have the task drawer off screen + taskList.setTaskOffsetValue(state.wasInActiveTask ? taskSwitcherHelpers.taskOffsetValue : taskSwitcherHelpers.homeOffsetValue, true); + + // task index from the 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); + } else { + // reset the task index to the start if on home screen + state.initialTaskIndex = 0 + } + state.currentTaskIndex = state.initialTaskIndex + + taskSwitcherHelpers.currentDisplayTask = state.currentTaskIndex; + + taskSwitcherHelpers.goToTaskIndex(state.initialTaskIndex); + taskList.minimizeAll(); + + // fully open the switcher (if this is a button press, not gesture) + if (!state.gestureInProgress) { + taskSwitcherHelpers.fromButton = true; + if (state.wasInActiveTask) { + taskList.setTaskOffsetValue(0, true); + } else { + taskList.setTaskOffsetValue(0); + } + backgroundColorOpacity = 1; + taskSwitcherHelpers.open(); + } + } + + // called by c++ plugin + function hideAnimation(): void { + closeAnim.restart(); + } + + function instantHide(): void { + state.deactivate(true); + } + + function hide(): void { + state.deactivate(false); + } + + Connections { + target: root.state + + function onActivated(): void { + root.tasksModel.refresh(); + root.initialSetup(); + } + + // task scrub mode allows scrubbing through a number of tasks with a mostly horizontal motion + function taskScrubMode(): void { + taskList.setTaskOffsetValue(0, false, Easing.OutQuart); + if (!root.taskSwitcherHelpers.isInTaskScrubMode) { + root.backgroundColorOpacity = 1; + root.taskSwitcherHelpers.cancelAnimations(); + root.taskSwitcherHelpers.open(); + if (!root.taskSwitcherHelpers.hasVibrated) { + // Haptic feedback when the task scrub mode engages + haptics.buttonVibrate(); + root.taskSwitcherHelpers.hasVibrated = true; + } + } + // TODO this makes sense, but makes scrub mode feel a bit weird + // improve trigger distance logic for task scrub mode to fix + let newTaskIndex = Math.max(0, Math.min(root.tasksCount - 1, Math.floor(root.state.touchXPosition / root.taskSwitcherHelpers.taskScrubDistance) + root.state.initialTaskIndex - (root.state.wasInActiveTask ? 0 : 1))); + if (newTaskIndex != root.state.currentTaskIndex || !root.taskSwitcherHelpers.isInTaskScrubMode) { + root.taskSwitcherHelpers.animateGoToTaskIndex(newTaskIndex); + root.taskSwitcherHelpers.isInTaskScrubMode = true; + } + } + + function onTouchPositionChanged(): void { + let unmodifiedYposition = Math.abs(root.state.touchYPosition) + if (root.taskSwitcherHelpers.isInTaskScrubMode || // once in scrub mode, let's not allow to go out, that can result in inconsistent UX + (Math.abs(root.state.xVelocity) > Math.abs(root.state.yVelocity) * 3 && // gesture needs to be almost completely horizontal + Math.abs(root.state.xVelocity) < 2.5 && // and not with a fast flick TODO! evaluate whether to keep this, it's kinda awkward + Math.abs(root.state.touchXPosition) > root.taskSwitcherHelpers.taskScrubDistance * 0.95 && // and have moved far enough sideways + unmodifiedYposition < Kirigami.Units.largeSpacing * 2 && // and be close to the screen edge + root.tasksCount > 0 && // and there needs to be more than none task open + !root.taskSwitcherHelpers.taskDrawerOpened // and the task drawer must not be open + )) { + taskScrubMode(); + } else { + if (root.taskSwitcherHelpers.currentlyBeingClosed) { + // if the task switch is still open but playing the close animation + // setup some values and return to the initial setup so that the user can always navigate with no down time + root.state.wasInActiveTask = root.taskSwitcherHelpers.openAppAnim.running ? true : false + taskList.setTaskOffsetValue(root.state.wasInActiveTask ? root.taskSwitcherHelpers.taskOffsetValue : root.taskSwitcherHelpers.homeOffsetValue, true); + root.state.status = !root.state.wasInActiveTask ? (root.taskSwitcherHelpers.openAppAnim.closeAnim && !root.taskSwitcherHelpers.taskDrawerWillOpen ? TaskSwitcherPlugin.MobileTaskSwitcherState.Active : TaskSwitcherPlugin.MobileTaskSwitcherState.Inactive) : TaskSwitcherPlugin.MobileTaskSwitcherState.Inactive + root.initialSetup(); + } else if (root.taskSwitcherHelpers.openAnim.running) { + root.taskSwitcherHelpers.cancelAnimations(); + root.state.status = root.taskSwitcherHelpers.stateClass.Active; + } + + root.state.yPosition = unmodifiedYposition + (root.taskSwitcherHelpers.taskDrawerOpened || !root.state.wasInActiveTask ? root.taskSwitcherHelpers.openedYPosition : 0); + + let newXPosition = root.taskSwitcherHelpers.xPositionFromTaskIndex(root.state.initialTaskIndex); + if (root.taskSwitcherHelpers.notHomeScreenState && !root.taskSwitcherHelpers.currentlyBeingClosed) { + newXPosition = newXPosition - (root.state.touchXPosition / root.taskSwitcherHelpers.currentScale); + } + root.state.xPosition = newXPosition; + + // allows the user to move the task drawer left and right when on the home screen + taskList.homeTouchPositionX = root.taskSwitcherHelpers.notHomeScreenState ? 0 : (root.state.touchXPosition * 0.35); + + // dynamically update the task switcher state based off of the touch position and velocity + updateTaskSwitcherState() + } + } + + function updateTaskSwitcherState(): void { + let unmodifiedYposition = Math.abs(root.state.touchYPosition) + + // if the touch is above heightThreshold, set reachedHeightThreshold to true + if (unmodifiedYposition > root.taskSwitcherHelpers.heightThreshold) { + // set reachedHeightThreshold when above or below two separate points to helps prevent flickering when the task switcher moves in and out of view + root.taskSwitcherHelpers.reachedHeightThreshold = true; + root.backgroundColorOpacity = root.taskSwitcherHelpers.notHomeScreenState ? 0 : 1; + } else if (unmodifiedYposition > root.taskSwitcherHelpers.undoYThreshold) { + root.backgroundColorOpacity = 1; + } else { + root.backgroundColorOpacity = root.taskSwitcherHelpers.notHomeScreenState ? 1 : 0; + } + + if (root.state.totalSquaredVelocity > root.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 (root.state.yVelocity > Math.abs(root.state.xVelocity) * xyVelocityRatio) { + // downwards flick + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Undecided); + if (unmodifiedYposition < root.taskSwitcherHelpers.undoYThreshold) { + taskList.setTaskOffsetValue(root.taskSwitcherHelpers.notHomeScreenState ? 0 : root.taskSwitcherHelpers.homeOffsetValue); + } + } else if (-root.state.yVelocity > Math.abs(root.state.xVelocity) * xyVelocityRatio || (root.taskSwitcherHelpers.reachedHeightThreshold && root.taskSwitcherHelpers.notHomeScreenState)) { + // upwards flick or if the touch is above heightThreshold + if (root.taskSwitcherHelpers.notHomeScreenState) { + // if in app or task switcher, go home + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home); + if (root.taskSwitcherHelpers.reachedHeightThreshold) { + taskList.setTaskOffsetValue(root.taskSwitcherHelpers.taskOffsetValue); + } + } else if (unmodifiedYposition > root.taskSwitcherHelpers.undoYThreshold) { + // else, keep the task switcher in view + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher); + taskList.setTaskOffsetValue(root.taskSwitcherHelpers.peekOffsetValue); + } + } else if (!root.taskSwitcherHelpers.reachedHeightThreshold && !root.taskSwitcherHelpers.isInTaskScrubMode) { + // sideways flick + if (root.taskSwitcherHelpers.notHomeScreenState) { + taskList.setTaskOffsetValue(0, unmodifiedYposition < root.taskSwitcherHelpers.openedYPosition ? true : false); + } + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.HorizontalSwipe); + } + } else { + if (unmodifiedYposition > root.taskSwitcherHelpers.undoYThreshold) { + // if just moveing out of undoYThreshold, set the state to home + if (root.taskSwitcherHelpers.gestureState < TaskSwitcherHelpers.GestureStates.TaskSwitcher) { + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home) + } + // if the touch is above heightThreshold, it will return home + if (unmodifiedYposition > root.taskSwitcherHelpers.heightThreshold) { + root.taskSwitcherHelpers.hasVibrated = true; + if (root.taskSwitcherHelpers.notHomeScreenState) { + // move the task switcher out of view + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home); + taskList.setTaskOffsetValue(root.taskSwitcherHelpers.taskOffsetValue); + } else { + // keep the task switcher in view when above heightThreshold and from home + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher); + taskList.setTaskOffsetValue(root.taskSwitcherHelpers.peekOffsetValue); + } + // minus largeSpacing from the heightThreshold to help prevent flickering when the task switcher moves in and out of view + } else if ((unmodifiedYposition < root.taskSwitcherHelpers.heightThreshold - Kirigami.Units.largeSpacing) || root.taskSwitcherHelpers.reachedHeightThreshold == false) { + // set reachedHeightThreshold when above or below two separate points to helps prevent flickering when the task switcher moves in and out of view + root.taskSwitcherHelpers.reachedHeightThreshold = false; + if (root.state.totalSquaredVelocity < root.state.flickVelocityThreshold && root.taskSwitcherHelpers.taskSwitchCanLaunch) { + // if velocity is small enough, move the task switcher into view + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher); + taskList.setTaskOffsetValue(root.taskSwitcherHelpers.notHomeScreenState ? 0 : root.taskSwitcherHelpers.peekOffsetValue); + } + } + } else { + // if under the undo threshold, it will go back to the task switcher if it is open + if (root.taskSwitcherHelpers.taskDrawerOpened) { + root.taskSwitcherHelpers.reachedHeightThreshold = false; + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher) + taskList.setTaskOffsetValue(0); + } else { + root.taskSwitcherHelpers.reachedHeightThreshold = false; + root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Undecided) + taskList.setTaskOffsetValue(root.taskSwitcherHelpers.notHomeScreenState ? 0 : root.taskSwitcherHelpers.homeOffsetValue); + } + } + } + } + + // 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(): void { + let newIndex = root.taskSwitcherHelpers.getNearestTaskIndex(); + root.taskSwitcherHelpers.openApp(newIndex); + } + + // diagonal quick switch gesture logic + function quickSwitch(): void { + // should "quick switch" to adjacent app in task switcher, but only if we were in an app before + let unmodifiedYposition = Math.abs(root.state.touchYPosition) + let newIndex = root.state.currentTaskIndex; + let shouldSwitch = false; + if (root.state.xVelocity > 0) { + if (root.taskSwitcherHelpers.notHomeScreenState) { + // flick to the right, go to the app on the left + newIndex = root.state.currentTaskIndex + 1; + } + if (newIndex < root.tasksCount) { + // switch only if flick doesn't go over end of list + shouldSwitch = true; + } + } else if (root.state.xVelocity < 0) { + if (root.taskSwitcherHelpers.notHomeScreenState) { + // flick to the left, go to app to the right + newIndex = root.state.currentTaskIndex - 1; + if (newIndex >= 0) { + // switch only if flick doesn't go over end of list + shouldSwitch = true; + } + } else { + // flick to the left on the home screen, dismiss the gesture + root.taskSwitcherHelpers.close(); + return; + } + } + if (shouldSwitch) { + if (!root.taskSwitcherHelpers.taskDrawerOpened && unmodifiedYposition < root.taskSwitcherHelpers.openedYPosition) { + // if in a app, switch it to the new task when it is under the openedYPosition + taskList.setTaskOffsetValue(0, unmodifiedYposition < root.taskSwitcherHelpers.openedYPosition && root.taskSwitcherHelpers.notHomeScreenState); + root.taskSwitcherHelpers.openApp(newIndex, Kirigami.Units.longDuration * 4, Easing.OutExpo); + } else { + // if already in the task switcher or above the openedYPosition, only change the focus to the new task + root.taskSwitcherHelpers.animateGoToTaskIndex(newIndex); + root.taskSwitcherHelpers.open(); + } + } else { + // if not switching, just open task switcher + root.taskSwitcherHelpers.animateGoToTaskIndex(root.state.currentTaskIndex); + root.taskSwitcherHelpers.open(); + } + } + + // Logic for deciding how to handle the end of a gesture input + function onGestureInProgressChanged(): void { + root.taskSwitcherHelpers.fromButton = false; + if (root.state.gestureInProgress) { + root.taskSwitcherHelpers.currentDisplayTask = root.state.currentTaskIndex; + return; + } + + if (taskList.count === 0) { + // dismiss the gesture if the task list is empty + root.taskSwitcherHelpers.close(); + } if (root.taskSwitcherHelpers.isInTaskScrubMode) { + // TODO! do we want to handle upwards flick to dismiss in task scrub mode? + let unmodifiedYposition = Math.abs(root.state.touchYPosition) + root.backgroundColorOpacity = 1; + if (root.taskSwitcherHelpers.taskDrawerOpened || unmodifiedYposition > root.taskSwitcherHelpers.undoYThreshold) { + root.taskSwitcherHelpers.animateGoToTaskIndex(root.state.currentTaskIndex); + root.taskSwitcherHelpers.open(); + root.taskSwitcherHelpers.isInTaskScrubMode = false; + } else { + root.taskSwitcherHelpers.openApp(root.state.currentTaskIndex); + } + } else if (root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.Undecided) { + if (root.taskSwitcherHelpers.taskDrawerOpened) { + // if in the task switcher, return to it + root.taskSwitcherHelpers.animateGoToTaskIndex(root.state.currentTaskIndex); + root.taskSwitcherHelpers.open(); + } else if (root.state.wasInActiveTask) { + // if inside a app, return to it + returnToApp(); + } else { + // else dismiss the gesture + root.taskSwitcherHelpers.close(); + } + } else if (root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.HorizontalSwipe) { + // sideways flick + root.backgroundColorOpacity = 1; + quickSwitch(); + } else if (root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.TaskSwitcher) { + // open the task drawer + root.backgroundColorOpacity = 1; + root.taskSwitcherHelpers.animateGoToTaskIndex(root.state.currentTaskIndex); + root.taskSwitcherHelpers.open(); + } else if (root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.Home) { + root.taskSwitcherHelpers.close(); + } + } + + function onVelocityChanged(): void { + + } + + function onXPositionChanged(): void { + root.taskSwitcherHelpers.updateTaskIndex(); + } + } + + // kind of a hack, but this prevents the gesture from immediately activating the task switcher when it is not supposed to + Timer { + id: taskSwitchCanLaunchTimer + interval: 1; running: true; repeat: false + onTriggered: root.taskSwitcherHelpers.taskSwitchCanLaunch = true; + } + + function setTaskDrawerState(value: int): void { + if (taskSwitcherHelpers.gestureState != TaskSwitcherHelpers.GestureStates.TaskSwitcher && value == TaskSwitcherHelpers.GestureStates.TaskSwitcher) { + // vibrate only if switching to task drawer + if (!taskSwitcherHelpers.hasVibrated) { + // Haptic feedback when the task scrub mode engages + haptics.buttonVibrate(); + taskSwitcherHelpers.hasVibrated = true; + } + + } + taskSwitcherHelpers.gestureState = value; + } + + // view of the desktop background + KWinComponents.DesktopBackground { + id: backgroundItem + activity: KWinComponents.Workspace.currentActivity + desktop: KWinComponents.Workspace.currentDesktop + outputName: root.targetScreen.name + } + + // background colour + Rectangle { + id: backgroundRect + anchors.fill: root + + opacity: container.opacity + color: { + return Qt.rgba(0, 0, 0, 0.6 * root.taskSwitcherHelpers.closingFactor * root.backgroundColorOpacity); + } + } + + // animate the background opacity based off of the state. + property real backgroundColorOpacity: 1 + Behavior on backgroundColorOpacity { + id: backgroundColorOpacityAn + NumberAnimation { + duration: Kirigami.Units.longDuration + } + } + + // status bar + // TODO: improve load times, it is quite slow + // MobileShell.StatusBar { + // id: statusBar + // z: 1 + // colorGroup: Kirigami.Theme.ComplementaryColorGroup + // backgroundColor: "transparent" + // + // height: root.topMargin + // anchors.top: parent.top + // anchors.left: parent.left + // anchors.right: parent.right + // } + + // Gesture panel + Component { + id: gesturePanelComponent + + MobileShell.GesturePanel { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Complementary + opaqueBar: false + + // Trigger home action on tap + onHandleClicked: { + if (taskList.count === 0) { + root.hide(); + } else { + if (taskList.count > 1 && + root.state.elapsedTimeSinceStart != -1 && + root.state.elapsedTimeSinceStart < root.state.doubleClickInterval) { + root.taskSwitcherHelpers.openApp(1); + return; + } + + const currentIndex = root.state.currentTaskIndex; + root.taskSwitcherHelpers.openApp(root.state.currentTaskIndex); + } + } + } + } + + // Navigation panel + Component { + id: navigationPanelComponent + + MobileShell.NavigationPanel { + id: navigationPanel + backgroundColor: Qt.rgba(0, 0, 0, 0.1) + foregroundColorGroup: Kirigami.Theme.Complementary + shadow: false + + isVertical: MobileShell.Constants.navigationPanelOnSide(root.width, root.height) + + MobileShellState.PanelSettingsDBusClient { + id: panelSettings + screenName: Screen.name + } + + leftPadding: panelSettings.navigationPanelLeftPadding + rightPadding: panelSettings.navigationPanelRightPadding + + leftAction: MobileShell.NavigationPanelAction { + enabled: true + iconSource: "mobile-task-switcher" + shrinkSize: 4 + + onTriggered: { + if (taskList.count === 0) { + root.hide(); + } else { + if (taskList.count > 1 && + root.state.elapsedTimeSinceStart != -1 && + root.state.elapsedTimeSinceStart < root.state.doubleClickInterval) { + root.taskSwitcherHelpers.openApp(1); + return; + } + + const currentIndex = root.state.currentTaskIndex; + root.taskSwitcherHelpers.openApp(root.state.currentTaskIndex); + } + } + } + + // home button + middleAction: MobileShell.NavigationPanelAction { + enabled: true + iconSource: "start-here-kde" + onTriggered: root.hide() + } + + // close app/keyboard button + rightAction: MobileShell.NavigationPanelAction { + enabled: true + iconSource: "mobile-close-app" + shrinkSize: 4 + + onTriggered: { + taskList.getTaskAt(root.state.currentTaskIndex).closeApp(); + } + } + + rightCornerAction: MobileShell.NavigationPanelAction { + visible: false + } + } + } + + Loader { + id: panelLoader + z: !root.taskSwitcherHelpers.currentlyBeingClosed ? 1 : 0 + sourceComponent: { + if (ShellSettings.Settings.navigationPanelEnabled) { + return navigationPanelComponent; + } else if (ShellSettings.Settings.gesturePanelEnabled) { + return gesturePanelComponent; + } else { + return null; + } + } + } + + states: [ + State { + name: "landscape" + when: MobileShell.Constants.navigationPanelOnSide(root.width, root.height) + AnchorChanges { + target: panelLoader + anchors { + right: root.right + top: root.top + bottom: root.bottom + left: undefined + } + } + PropertyChanges { + target: panelLoader + width: navRightMargin + anchors.topMargin: root.topMargin + } + }, + State { + name: "portrait" + when: !MobileShell.Constants.navigationPanelOnSide(root.width, root.height) + AnchorChanges { + target: panelLoader + anchors { + top: undefined + right: root.right + left: root.left + bottom: root.bottom + } + } + PropertyChanges { + target: panelLoader + height: navBottomMargin + } + } + ] + + // task list + Item { + id: container + + // provide shell margins + anchors.fill: root + anchors.leftMargin: root.leftMargin + anchors.rightMargin: root.rightMargin + anchors.bottomMargin: root.bottomMargin + anchors.topMargin: root.topMargin + + NumberAnimation on opacity { + id: closeAnim + running: false + to: 0 + duration: Kirigami.Units.longDuration + easing.type: Easing.InOutQuad + + onFinished: { + closeAllButton.closeRequested = false; + } + } + + // placeholder message + ColumnLayout { + id: placeholder + spacing: Kirigami.Units.gridUnit + + opacity: { + let baseOpacity = ((root.tasksCount === 0 && !root.taskSwitcherHelpers.currentlyBeingClosed) ? 0.9 : 0); + return root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.TaskSwitcher ? baseOpacity : 0; + } + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.veryLongDuration } } + + anchors.centerIn: container + + Kirigami.Icon { + id: icon + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.large + implicitHeight: Kirigami.Units.iconSizes.large + source: "edit-none-symbolic" + color: "white" + } + + Kirigami.Heading { + Layout.fillWidth: true + Layout.maximumWidth: root.width * 0.75 + Layout.alignment: Qt.AlignHCenter + color: "white" + level: 3 + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + text: i18n("No applications are running.") + } + } + + RowLayout { + id: scrubIconList + opacity: root.taskSwitcherHelpers.isInTaskScrubMode ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration } } + + anchors.bottom: container.bottom + anchors.right: container.horizontalCenter + anchors.bottomMargin: root.taskSwitcherHelpers.scrubModeBottomMargin + + anchors.rightMargin: { + let size = Kirigami.Units.iconSizes.large + Kirigami.Units.largeSpacing * 2; + let offset = (root.state.currentTaskIndex + 0.5) * size; + return -offset; + } + Behavior on anchors.rightMargin { + NumberAnimation { + duration: root.taskSwitcherHelpers.xAnimDuration; + easing.type: root.taskSwitcherHelpers.xAnimEasingType; + } + } + + spacing: Kirigami.Units.largeSpacing * 2 + + layoutDirection: Qt.RightToLeft + + Repeater { + model: root.tasksModel + + delegate: Kirigami.Icon { + id: iconDelegate + + required property QtObject window + required property int index + + readonly property bool isCenteredIcon: iconDelegate.index === root.state.currentTaskIndex; + Layout.preferredHeight: isCenteredIcon ? Kirigami.Units.iconSizes.huge : Kirigami.Units.iconSizes.large + Layout.preferredWidth: isCenteredIcon ? Kirigami.Units.iconSizes.huge : Kirigami.Units.iconSizes.large + Layout.alignment: Qt.AlignVCenter + source: iconDelegate.window.icon + } + } + } + + RowLayout { + id: scrubIndicator + opacity: root.taskSwitcherHelpers.isInTaskScrubMode ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 200 } } + + anchors.bottom: container.bottom + anchors.horizontalCenter: container.horizontalCenter + anchors.bottomMargin: root.taskSwitcherHelpers.openedYPosition * 1 / 4 + + Kirigami.Icon { + id: iconScrubBack + opacity: root.state.currentTaskIndex == 0 ? 0.3 : 1 + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration * 2; easing.type: Easing.OutExpo } } + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.medium + implicitHeight: Kirigami.Units.iconSizes.medium + source: "draw-arrow-back" + color: "white" + } + + Item { + width: root.taskSwitcherHelpers.windowWidth / 4 + } + + Kirigami.Icon { + id: iconScrubFront + opacity: root.state.currentTaskIndex == root.tasksCount - 1 ? 0.3 : 1 + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration * 2; easing.type: Easing.OutExpo } } + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.medium + implicitHeight: Kirigami.Units.iconSizes.medium + source: "draw-arrow-forward" + color: "white" + } + } + + // flicking area for task switcher + FlickContainer { + id: flickable + anchors.fill: container + + taskSwitcherState: root.state + taskSwitcherHelpers: root.taskSwitcherHelpers + tasksCount: root.tasksCount + + // don't allow FlickContainer to steal from swiping on tasks + interactive: taskList.taskInteractingCount === 0 + + // the item is effectively anchored to the flickable bounds + TaskList { + id: taskList + taskSwitcher: root + shellTopMargin: root.topMargin + shellBottomMargin: root.bottomMargin + + + x: flickable.contentX + width: flickable.width + height: flickable.height + } + + PlasmaComponents.ToolButton { + id: closeAllButton + property bool closeRequested: false + visible: root.tasksCount !== 0 && !root.taskSwitcherHelpers.isInTaskScrubMode + enabled: !root.taskSwitcherHelpers.currentlyBeingClosed && !root.state.gestureInProgress + + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Complementary + + anchors { + bottom: taskList.bottom + bottomMargin: (taskList.taskYBase) * 0.75 + horizontalCenter: taskList.horizontalCenter + } + + opacity: (root.taskSwitcherHelpers.currentlyBeingClosed || root.state.gestureInProgress || !root.taskSwitcherHelpers.taskDrawerOpened) ? 0.0 : 1.0 + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } } + + icon.name: "edit-clear-history" + font.bold: true + + text: closeRequested ? i18n("Confirm Close All") : i18n("Close All") + + onClicked: { + if (closeRequested) { + taskList.closeAll(); + } else { + closeRequested = true; + } + } + } + } + } +} diff --git a/tools/plasma-mobile/prebuilt/libmobiletaskswitcherplugin.so b/tools/plasma-mobile/prebuilt/libmobiletaskswitcherplugin.so new file mode 100755 index 000000000..835019462 Binary files /dev/null and b/tools/plasma-mobile/prebuilt/libmobiletaskswitcherplugin.so differ diff --git a/tools/plasma-mobile/prebuilt/org.kde.plasma.mobile.homescreen.folio.so b/tools/plasma-mobile/prebuilt/org.kde.plasma.mobile.homescreen.folio.so new file mode 100755 index 000000000..0d5f2f963 Binary files /dev/null and b/tools/plasma-mobile/prebuilt/org.kde.plasma.mobile.homescreen.folio.so differ diff --git a/tools/plasma-mobile/rebuild.sh b/tools/plasma-mobile/rebuild.sh new file mode 100755 index 000000000..490078892 --- /dev/null +++ b/tools/plasma-mobile/rebuild.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +image_name=${DROIDDECK_PLASMA_BUILD_IMAGE:-droiddeck-plasma-mobile-builder:6.7.5} + +if ! docker image inspect "${image_name}" >/dev/null 2>&1; then + docker build --platform linux/arm64 -t "${image_name}" \ + -f "${repo_root}/tools/plasma-mobile/Dockerfile" "${repo_root}/tools/plasma-mobile" +fi + +docker run --rm --platform linux/arm64 \ + --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "${repo_root}:/src" -w /src "${image_name}" \ + bash /src/tools/plasma-mobile/build-in-container.sh + +prebuilt="${repo_root}/tools/plasma-mobile/prebuilt" +asset_root="${repo_root}/app/src/main/assets/linuxfs" +cp "${asset_root}/usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" \ + "${prebuilt}/org.kde.plasma.mobile.homescreen.folio.so" +cp "${asset_root}/usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so" \ + "${prebuilt}/libmobiletaskswitcherplugin.so" +cp "${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml" \ + "${prebuilt}/FlickContainer.qml" +cp "${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml" \ + "${prebuilt}/TaskSwitcher.qml" +echo "Refreshed the Plasma Mobile backports from ${image_name##*:}."