// SPDX-FileCopyrightText: 2023 Devin Lin // SPDX-License-Identifier: LGPL-2.0-or-later import QtQuick 2.12 import QtQuick.Window 2.12 import QtQuick.Layouts 1.1 import QtCore import Qt.labs.folderlistmodel import org.kde.plasma.components 3.0 as PC3 import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings import org.kde.taskmanager as TaskManager import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin import org.kde.kirigami as Kirigami import QtQuick.Controls as Controls import QtQuick.Templates as T import "./private" import "./delegate" MouseArea { id: root property Folio.HomeScreen folio property MobileShell.MaskManager maskManager property var homeScreen property bool suppressRunningTasks: false signal delegateDragRequested(var item) // Convergence mode: show running apps alongside favourites readonly property bool convergenceMode: ShellSettings.Settings.convergenceModeEnabled readonly property bool showRunningTasks: convergenceMode && !suppressRunningTasks readonly property int totalItemCount: repeater.count + (showRunningTasks ? taskRepeater.count : 0) readonly property int longAnimationDuration: MobileShell.Motion.duration(MobileShell.Motion.SpatialDefault) readonly property int shortAnimationDuration: MobileShell.Motion.duration(MobileShell.Motion.EffectsFast) // In convergence mode, size icons to fit the dock bar instead of using page grid cells property real dockCellWidth: convergenceMode ? root.height : folio.HomeScreenState.pageCellWidth property real dockCellHeight: convergenceMode ? root.height : folio.HomeScreenState.pageCellHeight Behavior on dockCellWidth { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } Behavior on dockCellHeight { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } // Navigation buttons width (used to offset center positioning) property real navButtonWidth: convergenceMode ? root.height : 0 property real dockItemInset: convergenceMode ? Math.max(2, Kirigami.Units.smallSpacing / 2) : 0 property real dockIconSize: Math.min(root.height * 0.56, Kirigami.Units.iconSizes.large) Behavior on navButtonWidth { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } Behavior on dockItemInset { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } Behavior on dockIconSize { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } function dockItemColor(pressed, hovered, active) { if (pressed) { return Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.18) } if (active) { const accent = MobileShell.SurfaceColors.accent() return Qt.rgba(accent.r, accent.g, accent.b, hovered ? 0.18 : 0.12) } if (hovered) { return Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.08) } return "transparent" } // Visible spacer between pinned favourites and running tasks readonly property bool showSpacer: showRunningTasks && repeater.count > 0 && taskRepeater.count > 0 property real spacerWidth: showSpacer ? Kirigami.Units.largeSpacing * 2 : 0 Behavior on spacerWidth { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } // Thumbnail popup hover tracking property int hoveredTaskIndex: -1 // Drag-reorder state (convergence mode only) property int dragReorderIndex: -1 property real dragReorderOffset: 0 readonly property int dragTargetIndex: { if (dragReorderIndex === -1) return -1 let shift = Math.round(dragReorderOffset / dockCellWidth) return Math.max(0, Math.min(repeater.count - 1, dragReorderIndex + shift)) } // Drag-to-pin state for running tasks in convergence mode. property int taskPinDragIndex: -1 property real taskPinDragOffset: 0 property int taskPinTargetIndex: -1 property string taskPinStorageId: "" readonly property bool taskPinCanDrop: taskPinTargetIndex !== -1 && taskPinStorageId !== "" // Virtual desktop pager (convergence mode, 2+ desktops) readonly property bool showPager: convergenceMode && virtualDesktopInfo.numberOfDesktops > 1 property real pagerButtonWidth: showPager ? Math.min(root.height, Kirigami.Units.gridUnit * 2.5) : 0 property int pagerWheelDelta: 0 property bool pagerWheelLocked: false readonly property int pagerLeftCount: showPager ? virtualDesktopInfo.numberOfDesktops : 0 property real desktopButtonWidth: convergenceMode ? root.height : 0 property real trashButtonWidth: convergenceMode ? root.height : 0 property real searchButtonWidth: convergenceMode ? root.height : 0 readonly property real leftControlsWidth: convergenceMode ? navButtonWidth + desktopButtonWidth + pagerLeftCount * pagerButtonWidth : 0 readonly property real rightControlsWidth: convergenceMode ? navButtonWidth + searchButtonWidth + trashButtonWidth : 0 readonly property real dockCenterX: convergenceMode ? leftControlsWidth + (root.width - leftControlsWidth - rightControlsWidth) / 2 : root.width / 2 Behavior on pagerButtonWidth { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } Behavior on desktopButtonWidth { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } Behavior on trashButtonWidth { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } Behavior on searchButtonWidth { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } property Item pendingDockToolTipItem: null property Item activeDockToolTipItem: null Timer { id: dockToolTipTimer interval: Kirigami.Units.toolTipDelay onTriggered: { root.activeDockToolTipItem = root.pendingDockToolTipItem root.pendingDockToolTipItem = null dockToolTipHideTimer.restart() } } Timer { id: dockToolTipHideTimer interval: Kirigami.Units.toolTipDelay * 3 onTriggered: root.hideDockToolTip(root.activeDockToolTipItem) } Timer { id: pagerWheelEndTimer interval: 160 onTriggered: { root.pagerWheelDelta = 0 root.pagerWheelLocked = false } } function requestDockToolTip(item) { activeDockToolTipItem = null pendingDockToolTipItem = item dockToolTipHideTimer.stop() dockToolTipTimer.restart() } function hideDockToolTip(item) { if (!item || pendingDockToolTipItem === item || activeDockToolTipItem === item) { pendingDockToolTipItem = null activeDockToolTipItem = null dockToolTipTimer.stop() dockToolTipHideTimer.stop() } } function pagerDesktopName(index) { let names = virtualDesktopInfo.desktopNames if (names && index < names.length && String(names[index]).length > 0) return String(names[index]) return i18n("Desktop %1", index + 1) } function pagerDesktopNameForId(desktopId) { let ids = virtualDesktopInfo.desktopIds if (!ids) { return "" } for (let i = 0; i < ids.length; ++i) { if (String(ids[i]) === String(desktopId)) { return root.pagerDesktopName(i) } } return "" } function menuDesktopIds(isOnAllDesktops) { let ids = virtualDesktopInfo.desktopIds if (!ids || ids.length <= 1) { return [] } let result = [] for (let i = 0; i < ids.length; ++i) { if (isOnAllDesktops || String(ids[i]) !== String(virtualDesktopInfo.currentDesktop)) { result.push(ids[i]) } } return result } function desktopIndexForId(desktopId) { let ids = virtualDesktopInfo.desktopIds if (!ids) { return -1 } for (let i = 0; i < ids.length; ++i) { if (String(ids[i]) === String(desktopId)) { return i } } return -1 } function activateAdjacentDesktop(direction) { let ids = virtualDesktopInfo.desktopIds if (!ids || ids.length <= 1) { return } let currentIndex = root.desktopIndexForId(virtualDesktopInfo.currentDesktop) if (currentIndex < 0) { return } let targetIndex = Math.max(0, Math.min(ids.length - 1, currentIndex + direction)) if (targetIndex !== currentIndex) { root.folio.activateVirtualDesktop(String(ids[targetIndex])) } } function handlePagerWheel(wheel) { if (!root.showPager) { return } const axisDelta = wheel.angleDelta.y || -wheel.angleDelta.x if (axisDelta === 0) { return } pagerWheelEndTimer.restart() if (root.pagerWheelLocked) { return } root.pagerWheelDelta += axisDelta * (wheel.inverted ? -1 : 1) if (root.pagerWheelDelta >= 120) { root.pagerWheelLocked = true root.pagerWheelDelta = 0 root.activateAdjacentDesktop(-1) } else if (root.pagerWheelDelta <= -120) { root.pagerWheelLocked = true root.pagerWheelDelta = 0 root.activateAdjacentDesktop(1) } } function dynamicTilingMoveToDesktopAction(desktopId) { let index = root.desktopIndexForId(desktopId) if (index < 0) { return "" } return "move-to-desktop:" + String(desktopId) + "|" + String(index + 1) } function taskActivities(taskModel) { let activities = taskModel.Activities return activities ? activities : [] } function taskWindowId(taskModel) { let winIds = taskModel.WinIdList return winIds && winIds.length === 1 ? String(winIds[0]) : "" } function activityName(activityId) { let name = activityInfo.activityName(activityId) return name && name.length > 0 ? name : i18n("Activity") } function menuActivityIds(taskActivities) { let runningActivities = activityInfo.runningActivities() if (!runningActivities || runningActivities.length <= 1) { return [] } let result = [] for (let i = 0; i < runningActivities.length; ++i) { let activityId = runningActivities[i] if (taskActivities.length === 1 && String(taskActivities[0]) === String(activityId)) { continue } result.push(activityId) } return result } // Returns the desktop ID of the pager button under screen-space x, or "" function pagerButtonDesktopAt(x) { if (!showPager) return "" let ids = virtualDesktopInfo.desktopIds for (let i = 0; i < pagerLeftCount; ++i) { let bx = navButtonWidth + desktopButtonWidth + i * pagerButtonWidth if (x >= bx && x < bx + pagerButtonWidth) return (ids && i < ids.length) ? String(ids[i]) : "" } return "" } function focusFirstDockContent() { let firstPager = leftPagerRepeater.itemAt(0) if (firstPager) { firstPager.forceActiveFocus(); return } root.focusAfterPager() } function focusAfterPager() { let first = repeater.itemAt(0) if (first) { first.keyboardFocus(); return } let firstTask = taskRepeater.itemAt(0) if (firstTask) { firstTask.forceActiveFocus(); return } searchButton.forceActiveFocus() } function focusBeforeSearch() { let lastTask = taskRepeater.itemAt(taskRepeater.count - 1) if (lastTask) { lastTask.forceActiveFocus(); return } let lastFav = repeater.itemAt(repeater.count - 1) if (lastFav) { lastFav.keyboardFocus(); return } let lastPager = leftPagerRepeater.itemAt(leftPagerRepeater.count - 1) if (lastPager) { lastPager.forceActiveFocus(); return } desktopButton.forceActiveFocus() } function runningTaskStorageId(taskModel) { var id = taskModel ? taskModel.AppId || "" : "" if (id && !id.endsWith(".desktop")) id += ".desktop" return id } function favouriteBaseX(index) { return index * root.dockCellWidth - (root.totalItemCount / 2) * root.dockCellWidth + root.dockCenterX - root.spacerWidth / 2 } function taskBaseX(index) { return (repeater.count + index) * root.dockCellWidth - (root.totalItemCount / 2) * root.dockCellWidth + root.dockCenterX + root.spacerWidth / 2 } function clearTaskPinDrag() { root.taskPinDragIndex = -1 root.taskPinDragOffset = 0 root.taskPinTargetIndex = -1 root.taskPinStorageId = "" } function updateTaskPinTarget() { if (root.taskPinDragIndex === -1 || root.taskPinStorageId === "" || folio.FolioSettings.lockLayout || folio.FavouritesModel.containsApplication(root.taskPinStorageId)) { root.taskPinTargetIndex = -1 return } var draggedCenterX = root.taskBaseX(root.taskPinDragIndex) + root.dockCellWidth / 2 + root.taskPinDragOffset var firstTaskCenterX = root.taskBaseX(0) + root.dockCellWidth / 2 if (draggedCenterX >= firstTaskCenterX) { root.taskPinTargetIndex = -1 return } if (repeater.count === 0) { root.taskPinTargetIndex = 0 return } for (let index = 0; index < repeater.count; ++index) { let favouriteCenterX = root.favouriteBaseX(index) + root.dockCellWidth / 2 if (draggedCenterX < favouriteCenterX) { root.taskPinTargetIndex = index return } } root.taskPinTargetIndex = repeater.count } // Home button (convergence mode, left end) Rectangle { id: homeButton visible: root.convergenceMode || opacity > 0 enabled: root.convergenceMode opacity: root.convergenceMode ? 1 : 0 activeFocusOnTab: root.convergenceMode anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom width: root.navButtonWidth color: "transparent" Behavior on opacity { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } Accessible.role: Accessible.Button Accessible.name: i18n("Home") Accessible.onPressAction: MobileShellState.ShellDBusClient.openHomeScreen() Keys.onReturnPressed: MobileShellState.ShellDBusClient.openHomeScreen() Keys.onEnterPressed: MobileShellState.ShellDBusClient.openHomeScreen() Keys.onSpacePressed: MobileShellState.ShellDBusClient.openHomeScreen() Keys.onRightPressed: { desktopButton.forceActiveFocus() } KeyboardHighlight { anchors.fill: parent visible: homeButton.activeFocus } MobileShell.MotionStateLayer { anchors.fill: parent anchors.margins: root.dockItemInset radius: Kirigami.Units.cornerRadius hovered: homeMouseArea.containsMouse pressed: homeMouseArea.containsPress pressedOpacity: 0.18 } Kirigami.Icon { anchors.centerIn: parent width: root.dockIconSize height: width source: "start-here-shift" active: homeMouseArea.containsMouse isMask: true color: Kirigami.Theme.textColor } MouseArea { id: homeMouseArea anchors.fill: parent hoverEnabled: true cursorShape: root.convergenceMode ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: MobileShellState.ShellDBusClient.openHomeScreen() } } // Peek at Desktop button (convergence mode, next to Home) Rectangle { id: desktopButton visible: root.convergenceMode || opacity > 0 enabled: root.convergenceMode opacity: root.convergenceMode ? 1 : 0 activeFocusOnTab: root.convergenceMode x: root.navButtonWidth y: 0 width: root.desktopButtonWidth height: root.height color: "transparent" function toggleDesktop() { WindowPlugin.WindowUtil.requestShowingDesktop(!WindowPlugin.WindowUtil.isShowingDesktop) } Behavior on opacity { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } Accessible.role: Accessible.Button Accessible.name: WindowPlugin.WindowUtil.isShowingDesktop ? i18n("Stop Peeking at Desktop") : i18n("Peek at Desktop") Accessible.onPressAction: desktopButton.toggleDesktop() Keys.onReturnPressed: desktopButton.toggleDesktop() Keys.onEnterPressed: desktopButton.toggleDesktop() Keys.onSpacePressed: desktopButton.toggleDesktop() Keys.onLeftPressed: homeButton.forceActiveFocus() Keys.onRightPressed: root.focusFirstDockContent() KeyboardHighlight { anchors.fill: parent visible: desktopButton.activeFocus } MobileShell.MotionStateLayer { anchors.fill: parent anchors.margins: root.dockItemInset radius: Kirigami.Units.cornerRadius hovered: desktopMouseArea.containsMouse pressed: desktopMouseArea.containsPress active: WindowPlugin.WindowUtil.isShowingDesktop pressedOpacity: 0.18 } Kirigami.Icon { anchors.centerIn: parent width: root.dockIconSize height: width source: "user-desktop" active: desktopMouseArea.containsMouse || WindowPlugin.WindowUtil.isShowingDesktop isMask: true color: Kirigami.Theme.textColor } PC3.ToolTip { visible: root.activeDockToolTipItem === desktopButton && desktopMouseArea.containsMouse && !desktopMouseArea.containsPress && !desktopContextMenu.opened text: desktopButton.Accessible.name } MouseArea { id: desktopMouseArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: root.convergenceMode ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: (mouse) => { root.hideDockToolTip(desktopButton) if (mouse.button === Qt.RightButton) { desktopContextMenu.open() } else { desktopButton.toggleDesktop() } } onPressAndHold: { root.hideDockToolTip(desktopButton) desktopContextMenu.open() haptics.buttonVibrate() } onContainsMouseChanged: { if (containsMouse) { root.requestDockToolTip(desktopButton) } else { root.hideDockToolTip(desktopButton) } } onPressedChanged: { if (pressed) { root.hideDockToolTip(desktopButton) } } } PC3.Menu { id: desktopContextMenu popupType: T.Popup.Window PC3.MenuItem { icon.name: "user-desktop" text: desktopButton.Accessible.name onTriggered: desktopButton.toggleDesktop() } PC3.MenuItem { icon.name: "window-minimize-symbolic" text: i18n("Minimize All Windows") enabled: !WindowPlugin.WindowUtil.isShowingDesktop onTriggered: root.folio.triggerMinimizeAll() } } } // Overview button (convergence mode, right end) Rectangle { id: overviewButton visible: root.convergenceMode || opacity > 0 enabled: root.convergenceMode opacity: root.convergenceMode ? 1 : 0 activeFocusOnTab: root.convergenceMode anchors.right: parent.right anchors.top: parent.top anchors.bottom: parent.bottom width: root.navButtonWidth color: "transparent" Behavior on opacity { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } Accessible.role: Accessible.Button Accessible.name: i18n("Overview") Accessible.onPressAction: root.folio.triggerOverview() Keys.onReturnPressed: root.folio.triggerOverview() Keys.onEnterPressed: root.folio.triggerOverview() Keys.onSpacePressed: root.folio.triggerOverview() Keys.onLeftPressed: searchButton.forceActiveFocus() KeyboardHighlight { anchors.fill: parent visible: overviewButton.activeFocus } MobileShell.MotionStateLayer { anchors.fill: parent anchors.margins: root.dockItemInset radius: Kirigami.Units.cornerRadius hovered: overviewMouseArea.containsMouse pressed: overviewMouseArea.containsPress pressedOpacity: 0.18 } Kirigami.Icon { anchors.centerIn: parent width: root.dockIconSize height: width source: "activities" active: overviewMouseArea.containsMouse isMask: true color: Kirigami.Theme.textColor } MouseArea { id: overviewMouseArea anchors.fill: parent hoverEnabled: true cursorShape: root.convergenceMode ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: root.folio.triggerOverview() } } // Search button (convergence mode, immediately left of Overview) Rectangle { id: searchButton visible: root.convergenceMode || opacity > 0 enabled: root.convergenceMode opacity: root.convergenceMode ? 1 : 0 activeFocusOnTab: root.convergenceMode x: root.width - root.navButtonWidth - root.searchButtonWidth y: 0 width: root.searchButtonWidth height: root.height color: "transparent" Behavior on opacity { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } Accessible.role: Accessible.Button Accessible.name: i18n("Search") Accessible.onPressAction: root.folio.HomeScreenState.openSearchWidget() Keys.onReturnPressed: root.folio.HomeScreenState.openSearchWidget() Keys.onEnterPressed: root.folio.HomeScreenState.openSearchWidget() Keys.onSpacePressed: root.folio.HomeScreenState.openSearchWidget() Keys.onLeftPressed: trashButton.forceActiveFocus() Keys.onRightPressed: overviewButton.forceActiveFocus() KeyboardHighlight { anchors.fill: parent visible: searchButton.activeFocus } MobileShell.MotionStateLayer { anchors.fill: parent anchors.margins: root.dockItemInset radius: Kirigami.Units.cornerRadius hovered: searchMouseArea.containsMouse pressed: searchMouseArea.containsPress pressedOpacity: 0.18 } Kirigami.Icon { anchors.centerIn: parent width: root.dockIconSize height: width source: "search" active: searchMouseArea.containsMouse isMask: true color: Kirigami.Theme.textColor } PC3.ToolTip { visible: root.activeDockToolTipItem === searchButton && searchMouseArea.containsMouse && !searchMouseArea.containsPress text: i18n("Search") } MouseArea { id: searchMouseArea anchors.fill: parent hoverEnabled: true cursorShape: root.convergenceMode ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { root.hideDockToolTip(searchButton) root.folio.HomeScreenState.openSearchWidget() } onContainsMouseChanged: { if (containsMouse) { root.requestDockToolTip(searchButton) } else { root.hideDockToolTip(searchButton) } } onPressedChanged: { if (pressed) { root.hideDockToolTip(searchButton) } } } } // ---- Virtual desktop pager: compact group next to the Desktop button ---- Repeater { id: leftPagerRepeater model: root.pagerLeftCount delegate: Item { id: leftDesktopBtn required property int index readonly property string desktopId: { let ids = virtualDesktopInfo.desktopIds return (ids && index < ids.length) ? String(ids[index]) : "" } readonly property bool isCurrent: desktopId !== "" && String(desktopId) === String(virtualDesktopInfo.currentDesktop) readonly property bool isDragTarget: { if (root.taskPinDragIndex < 0) return false let cx = root.taskBaseX(root.taskPinDragIndex) + root.dockCellWidth / 2 + root.taskPinDragOffset return root.pagerButtonDesktopAt(cx) === desktopId } x: root.navButtonWidth + root.desktopButtonWidth + index * root.pagerButtonWidth y: 0 width: root.pagerButtonWidth height: root.height activeFocusOnTab: root.showPager function activateDesktop() { if (leftDesktopBtn.desktopId) { root.folio.activateVirtualDesktop(leftDesktopBtn.desktopId) } } Accessible.role: Accessible.Button Accessible.name: root.pagerDesktopName(leftDesktopBtn.index) Accessible.onPressAction: leftDesktopBtn.activateDesktop() Keys.onReturnPressed: leftDesktopBtn.activateDesktop() Keys.onEnterPressed: leftDesktopBtn.activateDesktop() Keys.onSpacePressed: leftDesktopBtn.activateDesktop() Keys.onLeftPressed: { let previous = leftPagerRepeater.itemAt(index - 1) if (previous) { previous.forceActiveFocus(); return } desktopButton.forceActiveFocus() } Keys.onRightPressed: { let next = leftPagerRepeater.itemAt(index + 1) if (next) { next.forceActiveFocus(); return } root.focusAfterPager() } KeyboardHighlight { anchors.fill: parent visible: leftDesktopBtn.activeFocus } MobileShell.MotionStateLayer { anchors.fill: parent anchors.margins: root.dockItemInset radius: Kirigami.Units.cornerRadius hovered: leftPagerHover.containsMouse pressed: leftPagerHover.containsPress active: leftDesktopBtn.isCurrent || leftDesktopBtn.isDragTarget pressedOpacity: 0.18 activeOpacity: 0.18 activeHoverOpacity: 0.25 } PC3.Label { anchors.centerIn: parent text: (leftDesktopBtn.index + 1).toString() color: leftDesktopBtn.isCurrent ? MobileShell.SurfaceColors.accent() : Kirigami.Theme.textColor font.pixelSize: Math.round(parent.height * 0.3) font.bold: leftDesktopBtn.isCurrent } PC3.ToolTip { visible: root.activeDockToolTipItem === leftDesktopBtn && leftPagerHover.containsMouse && !leftPagerHover.containsPress && !leftPagerContextMenu.opened text: root.pagerDesktopName(leftDesktopBtn.index) } MouseArea { id: leftPagerHover anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.PointingHandCursor onWheel: (wheel) => root.handlePagerWheel(wheel) onClicked: (mouse) => { root.hideDockToolTip(leftDesktopBtn) if (mouse.button === Qt.RightButton) { leftPagerContextMenu.open() } else { leftDesktopBtn.activateDesktop() } } onPressAndHold: { root.hideDockToolTip(leftDesktopBtn) leftPagerContextMenu.open() haptics.buttonVibrate() } onContainsMouseChanged: { if (containsMouse) { root.requestDockToolTip(leftDesktopBtn) } else { root.hideDockToolTip(leftDesktopBtn) } } onPressedChanged: { if (pressed) { root.hideDockToolTip(leftDesktopBtn) } } } PC3.Menu { id: leftPagerContextMenu popupType: T.Popup.Window PC3.MenuItem { icon.name: "list-add" text: i18n("Add Virtual Desktop") onTriggered: root.folio.createVirtualDesktop() } PC3.MenuItem { icon.name: "list-remove" text: i18n("Remove Virtual Desktop") enabled: virtualDesktopInfo.numberOfDesktops > 1 onTriggered: root.folio.removeLastVirtualDesktop() } Controls.MenuSeparator {} PC3.MenuItem { icon.name: "preferences-desktop-virtual" text: i18n("Configure Virtual Desktops…") onTriggered: MobileShell.ShellUtil.executeCommand("plasma-open-settings kcm_kwin_virtualdesktops") } } } } // ---- Trash button (convergence mode, sits between Search and Overview) ---- // Watches ~/.local/share/Trash/files to detect empty/full state. // FolderListModel reacts to directory changes automatically. FolderListModel { id: trashFilesModel folder: StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/.local/share/Trash/files" showFiles: true showDirs: true showDotAndDotDot: false } // Confirmation dialog for "Empty Trash" — parented to the homescreen so it // is sized correctly and floats above all dock content. Loader { id: emptyTrashDialogLoader parent: root.homeScreen anchors.fill: parent active: false function open() { active = true; item.open(); } sourceComponent: Kirigami.PromptDialog { title: i18n("Empty Trash") subtitle: i18n("Permanently delete all items in the trash? This action cannot be undone.") standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.Cancel onAccepted: root.folio.emptyTrash() onClosed: emptyTrashDialogLoader.active = false } } Rectangle { id: trashButton visible: root.convergenceMode || opacity > 0 enabled: root.convergenceMode opacity: root.convergenceMode ? 1 : 0 activeFocusOnTab: root.convergenceMode x: root.width - root.navButtonWidth - root.searchButtonWidth - root.trashButtonWidth y: 0 width: root.trashButtonWidth height: root.height color: "transparent" Behavior on opacity { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } Accessible.role: Accessible.Button Accessible.name: i18n("Trash") Accessible.onPressAction: Qt.openUrlExternally("trash:/") Keys.onReturnPressed: Qt.openUrlExternally("trash:/") Keys.onEnterPressed: Qt.openUrlExternally("trash:/") Keys.onSpacePressed: Qt.openUrlExternally("trash:/") Keys.onLeftPressed: root.focusBeforeSearch() Keys.onRightPressed: searchButton.forceActiveFocus() KeyboardHighlight { anchors.fill: parent visible: trashButton.activeFocus } MobileShell.MotionStateLayer { anchors.fill: parent anchors.margins: root.dockItemInset radius: Kirigami.Units.cornerRadius hovered: trashMouseArea.containsMouse pressed: trashMouseArea.containsPress pressedOpacity: 0.18 } Kirigami.Icon { anchors.centerIn: parent width: root.dockIconSize height: width source: trashFilesModel.count > 0 ? "user-trash-full" : "user-trash" active: trashMouseArea.containsMouse isMask: true color: Kirigami.Theme.textColor } PC3.ToolTip { visible: root.activeDockToolTipItem === trashButton && trashMouseArea.containsMouse && !trashMouseArea.containsPress && !trashContextMenu.opened text: trashFilesModel.count > 0 ? i18np("Trash — 1 item", "Trash — %1 items", trashFilesModel.count) : i18n("Trash") } MouseArea { id: trashMouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (mouse) => { root.hideDockToolTip(trashButton) if (mouse.button === Qt.RightButton) { trashContextMenu.open() } else { Qt.openUrlExternally("trash:/") } } onContainsMouseChanged: { if (containsMouse) { root.requestDockToolTip(trashButton) } else { root.hideDockToolTip(trashButton) } } onPressedChanged: { if (pressed) { root.hideDockToolTip(trashButton) } } } PC3.Menu { id: trashContextMenu popupType: T.Popup.Window PC3.MenuItem { icon.name: "folder-open" text: i18n("Open Trash") onTriggered: Qt.openUrlExternally("trash:/") } PC3.MenuItem { icon.name: "trash-empty" text: i18n("Empty Trash") enabled: trashFilesModel.count > 0 onTriggered: emptyTrashDialogLoader.open() } } } TaskManager.VirtualDesktopInfo { id: virtualDesktopInfo } TaskManager.ActivityInfo { id: activityInfo } TaskManager.TasksModel { id: tasksModel filterByVirtualDesktop: true filterByActivity: true filterNotMaximized: false filterByScreen: true filterHidden: false virtualDesktop: virtualDesktopInfo.currentDesktop activity: activityInfo.currentActivity groupMode: TaskManager.TasksModel.GroupApplications } acceptedButtons: Qt.LeftButton | Qt.RightButton onPressAndHold: { folio.HomeScreenState.openSettingsView(); haptics.buttonVibrate(); } onClicked: (mouse) => { if (mouse.button === Qt.RightButton) { if (convergenceMode) { root.homeScreen.showDesktopContextMenu(); } else { folio.HomeScreenState.openSettingsView(); } } } onDoubleClicked: { if (folio.FolioSettings.doubleTapToLock) { deviceLock.triggerLock(); } } onActiveFocusChanged: { if (activeFocus) { // Focus on first delegate when favorites bar focused let firstDelegate = repeater.itemAt(0); if (!firstDelegate) { return; } firstDelegate.keyboardFocus(); } } MobileShell.HapticsEffect { id: haptics } MobileShell.DeviceLock { id: deviceLock } Repeater { id: repeater model: folio.FavouritesModel delegate: Item { id: delegate readonly property var delegateModel: model.delegate readonly property int index: model.index readonly property var dragState: folio.HomeScreenState.dragState readonly property bool isDropPositionThis: dragState.candidateDropPosition.location === Folio.DelegateDragPosition.Favourites && dragState.candidateDropPosition.favouritesPosition === delegate.index readonly property bool isAppHoveredOver: folio.HomeScreenState.isDraggingDelegate && dragState.dropDelegate && dragState.dropDelegate.type === Folio.FolioDelegate.Application && isDropPositionThis readonly property bool isLocationBottom: folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Bottom // get the normalized index position value from the center so we can animate it property double fromCenterValue: model.index - (root.totalItemCount / 2) Behavior on fromCenterValue { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } // multiply the 'fromCenterValue' by the cell size to get the actual position readonly property int centerPosition: (isLocationBottom ? root.dockCellWidth : root.dockCellHeight) * fromCenterValue // Visual shift during drag-reorder: dragged item follows cursor, // displaced items slide to make room. property real dragVisualShift: { if (root.dragReorderIndex === -1) return 0 if (delegate.index === root.dragReorderIndex) return root.dragReorderOffset let targetIdx = root.dragTargetIndex let myIdx = delegate.index let dragIdx = root.dragReorderIndex let cellW = root.dockCellWidth if (targetIdx > dragIdx && myIdx > dragIdx && myIdx <= targetIdx) return -cellW if (targetIdx < dragIdx && myIdx >= targetIdx && myIdx < dragIdx) return cellW return 0 } Behavior on dragVisualShift { enabled: root.dragReorderIndex !== -1 && delegate.index !== root.dragReorderIndex MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } property real taskPinVisualShift: root.taskPinCanDrop && delegate.index >= root.taskPinTargetIndex ? root.dockCellWidth : 0 x: (isLocationBottom ? root.favouriteBaseX(delegate.index) : (parent.width - width) / 2) + dragVisualShift + taskPinVisualShift y: isLocationBottom ? (parent.height - height) / 2 : parent.height / 2 - centerPosition - root.dockCellHeight implicitWidth: root.dockCellWidth implicitHeight: root.dockCellHeight width: root.dockCellWidth height: root.dockCellHeight // Keyboard navigation to other delegates Keys.onPressed: (event) => { switch (event.key) { case Qt.Key_Up: if (!isLocationBottom) { let nextDelegate = repeater.itemAt(delegate.index - 1); if (nextDelegate) { nextDelegate.keyboardFocus(); event.accepted = true; } } break; case Qt.Key_Down: if (!isLocationBottom) { let nextDelegate = repeater.itemAt(delegate.index + 1); if (nextDelegate) { nextDelegate.keyboardFocus(); event.accepted = true; } } break; case Qt.Key_Left: if (isLocationBottom) { let prevDelegate = repeater.itemAt(delegate.index - 1); if (prevDelegate) { prevDelegate.keyboardFocus(); event.accepted = true; } else if (root.convergenceMode) { desktopButton.forceActiveFocus(); event.accepted = true; } } break; case Qt.Key_Right: if (isLocationBottom) { let nextDelegate = repeater.itemAt(delegate.index + 1); if (nextDelegate) { nextDelegate.keyboardFocus(); event.accepted = true; } else if (root.convergenceMode) { let firstTask = taskRepeater.itemAt(0); if (firstTask) { firstTask.forceActiveFocus(); } else { searchButton.forceActiveFocus(); } event.accepted = true; } } break; } } function keyboardFocus() { if (loader.item) { loader.item.keyboardFocus(); } } Loader { id: loader anchors.fill: parent sourceComponent: { if (delegate.delegateModel.type === Folio.FolioDelegate.Application) { return appComponent; } else if (delegate.delegateModel.type === Folio.FolioDelegate.Folder) { return folderComponent; } else { // ghost entry return placeholderComponent; } } } Component { id: placeholderComponent // square that shows when hovering over a spot to drop a delegate on (ghost entry) PlaceholderDelegate { id: dragDropFeedback folio: root.folio width: root.dockCellWidth height: root.dockCellHeight } } Component { id: appComponent AppDelegate { id: appDelegate folio: root.folio maskManager: root.maskManager application: delegate.delegateModel.application name: folio.FolioSettings.showFavouritesAppLabels ? delegate.delegateModel.application.name : "" shadow: !root.convergenceMode turnToFolder: delegate.isAppHoveredOver turnToFolderAnimEnabled: folio.HomeScreenState.isDraggingDelegate // do not show if the drop animation is running to this delegate visible: !(root.homeScreen.dropAnimationRunning && delegate.isDropPositionThis) // don't show label in drag and drop mode labelOpacity: delegate.opacity // Convergence drag-reorder: click-and-drag to reorder onDraggingChanged: { if (root.convergenceMode && !folio.FolioSettings.lockLayout) { if (appDelegate.dragging) { contextMenu.close() root.dragReorderIndex = delegate.index root.dragReorderOffset = 0 } else { let from = root.dragReorderIndex let to = root.dragTargetIndex root.dragReorderIndex = -1 root.dragReorderOffset = 0 if (from !== -1 && to !== -1 && from !== to) { folio.FavouritesModel.moveEntry(from, to) } } } } onDragMoved: (deltaX) => { if (root.convergenceMode && !folio.FolioSettings.lockLayout) { root.dragReorderOffset = deltaX } } onPressAndHold: { // prevent editing if lock layout is enabled if (folio.FolioSettings.lockLayout) return; // In convergence mode, drag-reorder is handled by DragHandler; // only open the context menu on press-and-hold. if (!root.convergenceMode) { let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appDelegate.delegateItem); folio.HomeScreenState.startDelegateFavouritesDrag( mappedCoords.x, mappedCoords.y, appDelegate.pressPosition.x, appDelegate.pressPosition.y, delegate.index ); } contextMenu.open(); haptics.buttonVibrate(); } onPressAndHoldReleased: { // cancel the event if the delegate is not dragged if (!root.convergenceMode && folio.HomeScreenState.swipeState === Folio.HomeScreenState.AwaitingDraggingDelegate) { homeScreen.cancelDelegateDrag(); } } onRightMousePress: { contextMenu.open(); } ContextMenuLoader { id: contextMenu menuPopupType: root.convergenceMode ? T.Popup.Window : T.Popup.Item // close menu when drag starts Connections { target: folio.HomeScreenState function onDelegateDragStarted() { contextMenu.close(); } } actions: [ Kirigami.Action { icon.name: delegate.delegateModel.application.icon text: i18n("Launch") onTriggered: appDelegate.launchApp() }, Kirigami.Action { icon.name: "emblem-favorite" text: i18n("Remove from Dock") enabled: !folio.FolioSettings.lockLayout onTriggered: folio.FavouritesModel.removeEntry(delegate.index) } ] } } } Component { id: folderComponent AppFolderDelegate { id: appFolderDelegate folio: root.folio maskManager: root.maskManager shadow: !root.convergenceMode folder: delegate.delegateModel.folder name: folio.FolioSettings.showFavouritesAppLabels ? delegate.delegateModel.folder.name : "" // do not show if the drop animation is running to this delegate, and the drop delegate is a folder visible: !(root.homeScreen.dropAnimationRunning && delegate.isDropPositionThis && delegate.dragState.dropDelegate.type === Folio.FolioDelegate.Folder) appHoveredOver: delegate.isAppHoveredOver // don't show label in drag and drop mode labelOpacity: delegate.opacity onAfterClickAnimation: { const pos = homeScreen.prepareFolderOpen(appFolderDelegate.contentItem); folio.HomeScreenState.openFolder(pos.x, pos.y, delegate.delegateModel.folder); } // Convergence drag-reorder: click-and-drag to reorder onDraggingChanged: { if (root.convergenceMode && !folio.FolioSettings.lockLayout) { if (appFolderDelegate.dragging) { contextMenu.close() root.dragReorderIndex = delegate.index root.dragReorderOffset = 0 } else { let from = root.dragReorderIndex let to = root.dragTargetIndex root.dragReorderIndex = -1 root.dragReorderOffset = 0 if (from !== -1 && to !== -1 && from !== to) { folio.FavouritesModel.moveEntry(from, to) } } } } onDragMoved: (deltaX) => { if (root.convergenceMode && !folio.FolioSettings.lockLayout) { root.dragReorderOffset = deltaX } } onPressAndHold: { // prevent editing if lock layout is enabled if (folio.FolioSettings.lockLayout) return; if (!root.convergenceMode) { let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.delegateModel, appFolderDelegate.delegateItem); folio.HomeScreenState.startDelegateFavouritesDrag( mappedCoords.x, mappedCoords.y, appFolderDelegate.pressPosition.x, appFolderDelegate.pressPosition.y, delegate.index ); } contextMenu.open(); haptics.buttonVibrate(); } onPressAndHoldReleased: { // cancel the event if the delegate is not dragged if (!root.convergenceMode && folio.HomeScreenState.swipeState === Folio.HomeScreenState.AwaitingDraggingDelegate) { root.homeScreen.cancelDelegateDrag(); } } onRightMousePress: { contextMenu.open(); } ContextMenuLoader { id: contextMenu menuPopupType: root.convergenceMode ? T.Popup.Window : T.Popup.Item // close menu when drag starts Connections { target: folio.HomeScreenState function onDelegateDragStarted() { contextMenu.close(); } } actions: [ Kirigami.Action { icon.name: "emblem-favorite" text: i18n("Remove") onTriggered: deleteDialog.open() } ] ConfirmDeleteFolderDialogLoader { id: deleteDialog parent: root.homeScreen onAccepted: folio.FavouritesModel.removeEntry(delegate.index) } } } } } } // Running-app task icons (convergence mode only) Timer { id: thumbnailShowTimer interval: Kirigami.Units.toolTipDelay onTriggered: { thumbnailPopup.showing = true } } Timer { id: thumbnailHideTimer interval: 300 onTriggered: { thumbnailPopup.showing = false root.hoveredTaskIndex = -1 } } Window { id: thumbnailPopup property var targetDelegate: null property int taskIndex: -1 property var windowIds: [] property bool isGroup: false property bool popupHovered: false property bool showing: false function open() { showing = true } function close() { showing = false } readonly property bool opened: showing visible: showing || fadeAnim.running opacity: showing ? 1 : 0 Behavior on opacity { MobileShell.MotionNumberAnimation { id: fadeAnim type: MobileShell.Motion.EffectsFast duration: root.shortAnimationDuration } } flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus color: "transparent" readonly property real thumbWidth: windowIds.length <= 1 ? Kirigami.Units.gridUnit * 16 : Kirigami.Units.gridUnit * 12 width: Math.max(Kirigami.Units.gridUnit * 8, windowIds.length * thumbWidth + Math.max(0, windowIds.length - 1) * Kirigami.Units.smallSpacing + 2 * Kirigami.Units.smallSpacing) height: popupContent.implicitHeight + 2 * Kirigami.Units.smallSpacing // Position above the hovered dock icon, in global coordinates x: { if (!targetDelegate) return 0 var win = root.Window.window var screenLeft = win && win.screen ? win.screen.virtualX : 0 var screenRight = screenLeft + (win && win.screen ? win.screen.width : Screen.width) // The dock window is full-width, anchored to the screen's left edge. // targetDelegate.x is dock-local, so the global center of the icon is: var globalCenter = screenLeft + targetDelegate.x + targetDelegate.width / 2 return Math.max(screenLeft, Math.min(screenRight - width, globalCenter - width / 2)) } y: { var win = root.Window.window var screenTop = win && win.screen ? win.screen.virtualY : 0 var screenBottom = screenTop + (win && win.screen ? win.screen.height : Screen.height) // Dock is bottom-anchored; its top edge is at screenBottom - dock window height. var dockTop = screenBottom - (win ? win.height : root.height) return Math.max(screenTop, dockTop - height - Kirigami.Units.smallSpacing) } onShowingChanged: { if (!showing && !fadeAnim.running) { windowIds = [] targetDelegate = null taskIndex = -1 isGroup = false } } Connections { target: fadeAnim function onRunningChanged() { if (!fadeAnim.running && !thumbnailPopup.showing) { thumbnailPopup.windowIds = [] thumbnailPopup.targetDelegate = null thumbnailPopup.taskIndex = -1 thumbnailPopup.isGroup = false } } } Rectangle { anchors.fill: parent color: MobileShell.SurfaceColors.accentSurface(Kirigami.Theme.backgroundColor, 0.24, 0.12) border.color: Qt.rgba( Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.2) border.width: 1 radius: Kirigami.Units.cornerRadius // HoverHandler for popup-level hover tracking (does not // consume mouse events, so clicks still reach delegates). HoverHandler { id: popupHoverHandler onHoveredChanged: { thumbnailPopup.popupHovered = hovered if (hovered) { thumbnailHideTimer.stop() } else if (root.hoveredTaskIndex < 0) { thumbnailHideTimer.restart() } } } Row { id: popupContent anchors.fill: parent anchors.margins: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing Repeater { model: thumbnailPopup.windowIds.length delegate: MouseArea { id: thumbEntry width: thumbnailPopup.thumbWidth height: thumbColumn.implicitHeight hoverEnabled: true cursorShape: Qt.PointingHandCursor readonly property string childUuid: thumbnailPopup.windowIds[index] || "" readonly property string childTitle: { if (!thumbnailPopup.isGroup) return tasksModel.data(tasksModel.makeModelIndex(thumbnailPopup.taskIndex), 0) || "" return tasksModel.data(tasksModel.makeModelIndex(thumbnailPopup.taskIndex, index), 0) || "" } onClicked: { var idx = thumbnailPopup.isGroup ? tasksModel.makeModelIndex(thumbnailPopup.taskIndex, index) : tasksModel.makeModelIndex(thumbnailPopup.taskIndex) tasksModel.requestActivate(idx) thumbnailPopup.close() } Rectangle { anchors.fill: parent radius: Kirigami.Units.cornerRadius color: thumbEntry.containsMouse ? MobileShell.SurfaceColors.withAlpha(MobileShell.SurfaceColors.accent(), 0.15) : "transparent" } Column { id: thumbColumn width: parent.width spacing: Kirigami.Units.smallSpacing Item { width: parent.width height: width * 9 / 16 Loader { id: thumbPipeWireLoader active: thumbnailPopup.visible && thumbEntry.childUuid !== "" anchors.fill: parent sourceComponent: PipeWireThumbnail { windowUuid: thumbEntry.childUuid } } Kirigami.Icon { anchors.centerIn: parent width: Kirigami.Units.iconSizes.huge height: width source: thumbnailPopup.targetDelegate ? thumbnailPopup.targetDelegate.model.decoration : "" visible: !thumbPipeWireLoader.item || !thumbPipeWireLoader.item.hasThumbnail } MouseArea { id: closeButton width: Kirigami.Units.iconSizes.small height: width anchors.right: parent.right anchors.top: parent.top anchors.margins: Kirigami.Units.smallSpacing z: 1 visible: thumbEntry.containsMouse cursorShape: Qt.PointingHandCursor onClicked: { var idx = thumbnailPopup.isGroup ? tasksModel.makeModelIndex(thumbnailPopup.taskIndex, index) : tasksModel.makeModelIndex(thumbnailPopup.taskIndex) tasksModel.requestClose(idx) if (thumbnailPopup.windowIds.length <= 1) { thumbnailPopup.close() } } Kirigami.Icon { anchors.fill: parent source: "window-close-symbolic" isMask: true color: "white" } Rectangle { anchors.fill: parent radius: width / 2 color: Qt.rgba(0, 0, 0, 0.55) z: -1 } } } PC3.Label { width: parent.width text: thumbEntry.childTitle elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter maximumLineCount: 1 } } } } } } } // Separator between pinned favourites and running tasks Rectangle { id: dockSpacer visible: root.showSpacer x: (repeater.count - root.totalItemCount / 2) * root.dockCellWidth + root.dockCenterX - width / 2 y: parent.height * 0.28 width: Math.round(Kirigami.Units.devicePixelRatio) height: parent.height * 0.44 color: Kirigami.Theme.textColor opacity: 0.22 } PlaceholderDelegate { id: taskPinPlaceholder visible: root.taskPinCanDrop folio: root.folio width: root.dockCellWidth height: root.dockCellHeight x: root.favouriteBaseX(root.taskPinTargetIndex) y: (parent.height - height) / 2 z: 1 } Repeater { id: taskRepeater model: root.showRunningTasks ? tasksModel : null delegate: Item { id: taskDelegate required property int index required property var model activeFocusOnTab: root.convergenceMode readonly property bool isLocationBottom: folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Bottom readonly property string taskStorageId: root.runningTaskStorageId(taskDelegate.model) readonly property bool isGroupParent: taskDelegate.model.IsGroupParent === true readonly property bool dynamicTilingActive: root.convergenceMode && ShellSettings.Settings.dynamicTilingEnabled readonly property bool showFreeGeometryActions: !taskDelegate.isGroupParent && !taskDelegate.dynamicTilingActive readonly property bool canRequestDynamicTiling: taskDelegate.dynamicTilingActive && !taskDelegate.isGroupParent && root.taskWindowId(taskDelegate.model) !== "" readonly property int dynamicTilingWindowStateSerial: ShellSettings.Settings.dynamicTilingWindowStateSerial readonly property bool dynamicTilingMaximized: taskDelegate.canRequestDynamicTiling && taskDelegate.dynamicTilingWindowStateSerial >= 0 && ShellSettings.Settings.isDynamicTilingWindowMaximized(root.taskWindowId(taskDelegate.model)) readonly property bool canChangeVirtualDesktops: taskDelegate.model.IsVirtualDesktopsChangeable === true readonly property bool canChangeActivities: activityInfo.numberOfRunningActivities > 1 && !taskDelegate.isGroupParent readonly property bool dynamicTilingAllActivities: taskDelegate.canRequestDynamicTiling && root.taskActivities(taskDelegate.model).length === 0 readonly property bool canShowActivityScopeAction: taskDelegate.canChangeActivities && (!taskDelegate.canRequestDynamicTiling || taskDelegate.dynamicTilingAllActivities) readonly property bool canShowActivityMoveActions: taskDelegate.canChangeActivities && !taskDelegate.canRequestDynamicTiling Accessible.role: Accessible.Button Accessible.name: taskDelegate.model.display || "" Accessible.onPressAction: taskDelegate.activateTask() function activateTask() { var winIds = taskDelegate.model.WinIdList if (winIds && winIds.length > 1) { if (thumbnailPopup.opened && thumbnailPopup.taskIndex === taskDelegate.index) { thumbnailPopup.close() } else { thumbnailPopup.targetDelegate = taskDelegate thumbnailPopup.taskIndex = taskDelegate.index thumbnailPopup.windowIds = winIds thumbnailPopup.isGroup = taskDelegate.model.IsGroupParent === true thumbnailPopup.open() } } else { thumbnailPopup.close() tasksModel.requestActivate(tasksModel.makeModelIndex(taskDelegate.index)) } } function requestMoveToDesktop(desktopId) { if (taskDelegate.canRequestDynamicTiling) { let action = root.dynamicTilingMoveToDesktopAction(desktopId) if (action !== "") { ShellSettings.Settings.requestDynamicTilingWindowAction(root.taskWindowId(taskDelegate.model), action) return } } tasksModel.requestVirtualDesktops(tasksModel.makeModelIndex(taskDelegate.index), [desktopId]) } function requestMoveToNewDesktop() { if (taskDelegate.canRequestDynamicTiling) { ShellSettings.Settings.requestDynamicTilingWindowAction(root.taskWindowId(taskDelegate.model), "move-to-new-desktop") return } tasksModel.requestNewVirtualDesktop(tasksModel.makeModelIndex(taskDelegate.index)) } Keys.onReturnPressed: taskDelegate.activateTask() Keys.onEnterPressed: taskDelegate.activateTask() Keys.onSpacePressed: taskDelegate.activateTask() Keys.onLeftPressed: { let prev = taskRepeater.itemAt(taskDelegate.index - 1) if (prev) { prev.forceActiveFocus(); return } let lastFav = repeater.itemAt(repeater.count - 1) if (lastFav) { lastFav.keyboardFocus(); return } desktopButton.forceActiveFocus() } Keys.onRightPressed: { let next = taskRepeater.itemAt(taskDelegate.index + 1) if (next) { next.forceActiveFocus(); return } searchButton.forceActiveFocus() } // Position after all favourites property double fromCenterValue: (repeater.count + taskDelegate.index) - (root.totalItemCount / 2) Behavior on fromCenterValue { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault; duration: root.longAnimationDuration } } readonly property int centerPosition: (isLocationBottom ? root.dockCellWidth : root.dockCellHeight) * fromCenterValue x: isLocationBottom ? root.taskBaseX(taskDelegate.index) + (root.taskPinDragIndex === taskDelegate.index ? root.taskPinDragOffset : 0) : (parent.width - width) / 2 y: isLocationBottom ? (parent.height - height) / 2 : parent.height / 2 - centerPosition - root.dockCellHeight z: root.taskPinDragIndex === taskDelegate.index ? 2 : 0 implicitWidth: root.dockCellWidth implicitHeight: root.dockCellHeight width: root.dockCellWidth height: root.dockCellHeight // Hover highlight background Rectangle { anchors.fill: parent anchors.margins: root.dockItemInset radius: Kirigami.Units.cornerRadius color: root.dockItemColor(taskMouseArea.containsPress, taskMouseArea.containsMouse, taskDelegate.model.IsActive === true) Behavior on color { MobileShell.MotionColorAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } } KeyboardHighlight { anchors.fill: parent visible: taskDelegate.activeFocus } // Task icon Kirigami.Icon { anchors.centerIn: parent width: root.dockIconSize height: width source: taskDelegate.model.decoration active: taskMouseArea.containsMouse } Rectangle { visible: opacity > 0 anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter anchors.topMargin: Kirigami.Units.smallSpacing width: Kirigami.Units.smallSpacing * 3 height: Math.max(2, Math.round(Kirigami.Units.devicePixelRatio)) radius: height / 2 color: MobileShell.SurfaceColors.accent() opacity: taskDelegate.dynamicTilingMaximized ? 0.95 : 0 Behavior on opacity { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } } DragHandler { id: taskDragHandler target: null xAxis.enabled: true yAxis.enabled: false // Enable for unpinned tasks (pin-to-dock drag) and for ALL tasks // when the pager is showing so windows can be dragged to a desktop button. enabled: root.convergenceMode && taskDelegate.isLocationBottom && !folio.FolioSettings.lockLayout && taskDelegate.taskStorageId !== "" && (root.showPager || !folio.FavouritesModel.containsApplication(taskDelegate.taskStorageId)) onActiveChanged: { if (active) { thumbnailPopup.close() thumbnailShowTimer.stop() thumbnailHideTimer.stop() root.hoveredTaskIndex = -1 root.taskPinDragIndex = taskDelegate.index root.taskPinDragOffset = 0 root.taskPinTargetIndex = -1 root.taskPinStorageId = taskDelegate.taskStorageId } else if (root.taskPinDragIndex === taskDelegate.index) { // If released over a pager button, move the window to that desktop. let finalCenterX = root.taskBaseX(taskDelegate.index) + root.dockCellWidth / 2 + root.taskPinDragOffset let pagerDesktop = root.pagerButtonDesktopAt(finalCenterX) if (pagerDesktop && taskDelegate.model.IsVirtualDesktopsChangeable === true) { taskDelegate.requestMoveToDesktop(pagerDesktop) } else if (root.taskPinCanDrop && !folio.FavouritesModel.containsApplication(root.taskPinStorageId)) { folio.FavouritesModel.addApplicationAt(root.taskPinTargetIndex, root.taskPinStorageId) } root.clearTaskPinDrag() } } onTranslationChanged: { if (root.taskPinDragIndex === taskDelegate.index) { root.taskPinDragOffset = translation.x root.updateTaskPinTarget() } } onCanceled: { if (root.taskPinDragIndex === taskDelegate.index) { root.clearTaskPinDrag() } } } // Window indicator dots (one per sibling window of the same app) Row { anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.bottomMargin: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing / 2 Repeater { model: { var ids = taskDelegate.model.WinIdList return Math.max(1, ids ? ids.length : 0) } Rectangle { width: taskDelegate.model.IsActive === true ? Kirigami.Units.smallSpacing * 3 : Kirigami.Units.smallSpacing * 1.5 height: Math.max(2, Math.round(Kirigami.Units.devicePixelRatio)) radius: height / 2 color: MobileShell.SurfaceColors.accent() opacity: taskDelegate.model.IsActive === true ? 1.0 : 0.45 Behavior on width { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } } } } // Click to activate, middle-click to close, hover for thumbnail preview MouseArea { id: taskMouseArea anchors.fill: parent hoverEnabled: true cursorShape: root.convergenceMode ? Qt.PointingHandCursor : Qt.ArrowCursor acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton onClicked: (mouse) => { if (mouse.button === Qt.MiddleButton) { thumbnailPopup.close() tasksModel.requestClose(tasksModel.makeModelIndex(taskDelegate.index)); return } if (mouse.button === Qt.RightButton) { thumbnailPopup.close() thumbnailShowTimer.stop() taskContextMenu.open(); } else { taskDelegate.activateTask() } } onContainsMouseChanged: { if (containsMouse) { thumbnailHideTimer.stop() thumbnailPopup.targetDelegate = taskDelegate thumbnailPopup.taskIndex = taskDelegate.index var winIds = taskDelegate.model.WinIdList thumbnailPopup.windowIds = winIds ? winIds : [] thumbnailPopup.isGroup = taskDelegate.model.IsGroupParent === true root.hoveredTaskIndex = taskDelegate.index if (!thumbnailPopup.opened) { thumbnailShowTimer.restart() } } else { root.hoveredTaskIndex = -1 if (!thumbnailPopup.popupHovered) { thumbnailShowTimer.stop() thumbnailHideTimer.restart() } } } } PC3.Menu { id: taskContextMenu popupType: T.Popup.Window PC3.MenuItem { icon.name: "window-new" text: i18n("Open New Window") visible: taskDelegate.model.CanLaunchNewInstance === true height: visible ? implicitHeight : 0 onClicked: tasksModel.requestNewInstance(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: "window-pin" text: i18n("Pin to Dock") // repeater.count dependency forces re-evaluation when favourites change visible: taskDelegate.taskStorageId !== "" && repeater.count >= 0 && !folio.FavouritesModel.containsApplication(taskDelegate.taskStorageId) height: visible ? implicitHeight : 0 enabled: !folio.FolioSettings.lockLayout onClicked: folio.FavouritesModel.addApplication(taskDelegate.taskStorageId) } Controls.MenuSeparator { visible: taskDelegate.model.CanLaunchNewInstance === true || (taskDelegate.taskStorageId !== "" && repeater.count >= 0 && !folio.FavouritesModel.containsApplication(taskDelegate.taskStorageId)) height: visible ? implicitHeight : 0 } Controls.MenuSeparator { visible: taskDelegate.canRequestDynamicTiling height: visible ? implicitHeight : 0 } PC3.MenuItem { icon.name: "window" text: i18n("Float Window") visible: taskDelegate.canRequestDynamicTiling height: visible ? implicitHeight : 0 onClicked: ShellSettings.Settings.requestDynamicTilingWindowAction(root.taskWindowId(taskDelegate.model), "float") } PC3.MenuItem { icon.name: "view-grid-symbolic" text: i18n("Tile Window") visible: taskDelegate.canRequestDynamicTiling height: visible ? implicitHeight : 0 onClicked: ShellSettings.Settings.requestDynamicTilingWindowAction(root.taskWindowId(taskDelegate.model), "tile") } PC3.MenuItem { icon.name: taskDelegate.dynamicTilingMaximized ? "window-restore" : "window-maximize" text: taskDelegate.dynamicTilingMaximized ? i18n("Restore") : i18n("Maximize") visible: taskDelegate.canRequestDynamicTiling height: visible ? implicitHeight : 0 enabled: taskDelegate.model.IsMaximizable === true onClicked: ShellSettings.Settings.requestDynamicTilingWindowAction(root.taskWindowId(taskDelegate.model), "maximize-toggle") } PC3.MenuItem { icon.name: "transform-move" text: i18n("Move") visible: taskDelegate.showFreeGeometryActions height: visible ? implicitHeight : 0 enabled: taskDelegate.model.IsMovable === true onClicked: tasksModel.requestMove(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: "transform-scale" text: i18n("Resize") visible: taskDelegate.showFreeGeometryActions height: visible ? implicitHeight : 0 enabled: taskDelegate.model.IsResizable === true onClicked: tasksModel.requestResize(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize" text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize") enabled: taskDelegate.model.IsMinimizable === true onClicked: tasksModel.requestToggleMinimized(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize" text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize") visible: taskDelegate.showFreeGeometryActions height: visible ? implicitHeight : 0 enabled: taskDelegate.model.IsMaximizable === true onClicked: tasksModel.requestToggleMaximized(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: "window-keep-above" text: taskDelegate.model.IsKeepAbove ? i18n("Do Not Keep Above Others") : i18n("Keep Above Others") visible: taskDelegate.showFreeGeometryActions height: visible ? implicitHeight : 0 onClicked: tasksModel.requestToggleKeepAbove(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: "window-keep-below" text: taskDelegate.model.IsKeepBelow ? i18n("Do Not Keep Below Others") : i18n("Keep Below Others") visible: taskDelegate.showFreeGeometryActions height: visible ? implicitHeight : 0 onClicked: tasksModel.requestToggleKeepBelow(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: "view-fullscreen" text: taskDelegate.model.IsFullScreen ? i18n("Leave Fullscreen") : i18n("Fullscreen") visible: taskDelegate.showFreeGeometryActions height: visible ? implicitHeight : 0 enabled: taskDelegate.model.IsFullScreenable === true onClicked: tasksModel.requestToggleFullScreen(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: "window-close" text: { var ids = taskDelegate.model.WinIdList return (ids && ids.length > 1) ? i18n("Close All") : i18n("Close") } enabled: taskDelegate.model.IsClosable === true onClicked: tasksModel.requestClose(tasksModel.makeModelIndex(taskDelegate.index)) } Controls.MenuSeparator { visible: taskDelegate.canChangeVirtualDesktops height: visible ? implicitHeight : 0 } PC3.MenuItem { icon.name: "virtual-desktops" text: taskDelegate.model.IsOnAllVirtualDesktops ? i18n("Show Only on Current Desktop") : i18n("Show on All Desktops") visible: taskDelegate.canChangeVirtualDesktops && virtualDesktopInfo.numberOfDesktops > 1 && (!taskDelegate.dynamicTilingActive || taskDelegate.model.IsOnAllVirtualDesktops === true) height: visible ? implicitHeight : 0 onClicked: { if (taskDelegate.dynamicTilingActive && taskDelegate.model.IsOnAllVirtualDesktops === true) { taskDelegate.requestMoveToDesktop(virtualDesktopInfo.currentDesktop) return } tasksModel.requestVirtualDesktops(tasksModel.makeModelIndex(taskDelegate.index), taskDelegate.model.IsOnAllVirtualDesktops ? [virtualDesktopInfo.currentDesktop] : []) } } Instantiator { model: root.showPager && taskDelegate.canChangeVirtualDesktops ? root.menuDesktopIds(taskDelegate.model.IsOnAllVirtualDesktops === true) : [] delegate: PC3.MenuItem { required property var modelData text: i18n("Move to %1", root.pagerDesktopNameForId(modelData)) onTriggered: taskDelegate.requestMoveToDesktop(modelData) } onObjectAdded: (idx, obj) => taskContextMenu.insertItem(taskContextMenu.count, obj) onObjectRemoved: (idx, obj) => taskContextMenu.removeItem(obj) } PC3.MenuItem { icon.name: "list-add" text: i18n("Move to New Desktop") visible: taskDelegate.canChangeVirtualDesktops height: visible ? implicitHeight : 0 onClicked: taskDelegate.requestMoveToNewDesktop() } Controls.MenuSeparator { visible: taskDelegate.canShowActivityScopeAction || taskDelegate.canShowActivityMoveActions height: visible ? implicitHeight : 0 } PC3.MenuItem { icon.name: "activities" text: root.taskActivities(taskDelegate.model).length === 0 ? i18n("Show Only on Current Activity") : i18n("Show on All Activities") visible: taskDelegate.canShowActivityScopeAction height: visible ? implicitHeight : 0 onClicked: tasksModel.requestActivities(tasksModel.makeModelIndex(taskDelegate.index), root.taskActivities(taskDelegate.model).length === 0 ? [activityInfo.currentActivity] : []) } Instantiator { model: taskDelegate.canShowActivityMoveActions ? root.menuActivityIds(root.taskActivities(taskDelegate.model)) : [] delegate: PC3.MenuItem { required property var modelData icon.name: activityInfo.activityIcon(modelData) text: i18n("Move to %1", root.activityName(modelData)) onTriggered: tasksModel.requestActivities( tasksModel.makeModelIndex(taskDelegate.index), [modelData]) } onObjectAdded: (idx, obj) => taskContextMenu.insertItem(taskContextMenu.count, obj) onObjectRemoved: (idx, obj) => taskContextMenu.removeItem(obj) } } } } }