// 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 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.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 signal delegateDragRequested(var item) // Convergence mode: show running apps alongside favourites readonly property bool convergenceMode: ShellSettings.Settings.convergenceModeEnabled readonly property int totalItemCount: repeater.count + (convergenceMode ? taskRepeater.count : 0) // In convergence mode, size icons to fit the dock bar instead of using page grid cells readonly property real dockCellWidth: convergenceMode ? root.height : folio.HomeScreenState.pageCellWidth readonly property real dockCellHeight: convergenceMode ? root.height : folio.HomeScreenState.pageCellHeight // Navigation buttons width (used to offset center positioning) readonly property real navButtonWidth: convergenceMode ? root.height : 0 // Center x for dock items (offset between nav buttons in convergence mode) readonly property real dockCenterX: convergenceMode ? navButtonWidth + (root.width - 2 * navButtonWidth) / 2 : root.width / 2 // Thumbnail popup hover tracking property int hoveredTaskIndex: -1 // Home button (convergence mode, left end) Rectangle { id: homeButton visible: root.convergenceMode anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom width: root.navButtonWidth color: homeMouseArea.containsPress ? Qt.rgba(255, 255, 255, 0.2) : (homeMouseArea.containsMouse ? Qt.rgba(255, 255, 255, 0.1) : "transparent") radius: Kirigami.Units.cornerRadius Kirigami.Icon { anchors.centerIn: parent width: Math.min(parent.width, parent.height) * 0.75 height: width source: "start-here-kde" active: homeMouseArea.containsMouse } MouseArea { id: homeMouseArea anchors.fill: parent hoverEnabled: true onClicked: MobileShellState.ShellDBusClient.openHomeScreen() } Controls.ToolTip.text: i18n("Home") Controls.ToolTip.visible: homeMouseArea.containsMouse Controls.ToolTip.delay: Kirigami.Units.toolTipDelay } // Overview button (convergence mode, right end) Rectangle { id: overviewButton visible: root.convergenceMode anchors.right: parent.right anchors.top: parent.top anchors.bottom: parent.bottom width: root.navButtonWidth color: overviewMouseArea.containsPress ? Qt.rgba(255, 255, 255, 0.2) : (overviewMouseArea.containsMouse ? Qt.rgba(255, 255, 255, 0.1) : "transparent") radius: Kirigami.Units.cornerRadius Kirigami.Icon { anchors.centerIn: parent width: Math.min(parent.width, parent.height) * 0.75 height: width source: "view-grid-symbolic" active: overviewMouseArea.containsMouse } MouseArea { id: overviewMouseArea anchors.fill: parent hoverEnabled: true onClicked: root.folio.triggerOverview() } Controls.ToolTip.text: i18n("Overview") Controls.ToolTip.visible: overviewMouseArea.containsMouse Controls.ToolTip.delay: Kirigami.Units.toolTipDelay } 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.GroupDisabled } // Count how many windows share the same AppId as the task at the given index function windowCountForTask(taskIndex) { var appId = tasksModel.data(tasksModel.makeModelIndex(taskIndex), TaskManager.AbstractTasksModel.AppId) if (!appId) return 1 var count = 0 for (var i = 0; i < tasksModel.rowCount(); i++) { if (tasksModel.data(tasksModel.makeModelIndex(i), TaskManager.AbstractTasksModel.AppId) === appId) count++ } return count } acceptedButtons: Qt.LeftButton | Qt.RightButton onPressAndHold: { folio.HomeScreenState.openSettingsView(); haptics.buttonVibrate(); } onClicked: (mouse) => { // Right-click opens settings view (wallpaper/widgets), same as long-press if (mouse.button === Qt.RightButton) { 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 { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad; } } // multiply the 'fromCenterValue' by the cell size to get the actual position readonly property int centerPosition: (isLocationBottom ? root.dockCellWidth : root.dockCellHeight) * fromCenterValue x: isLocationBottom ? centerPosition + root.dockCenterX : (parent.width - width) / 2 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 nextDelegate = repeater.itemAt(delegate.index - 1); if (nextDelegate) { nextDelegate.keyboardFocus(); event.accepted = true; } } break; case Qt.Key_Right: if (isLocationBottom) { let nextDelegate = repeater.itemAt(delegate.index + 1); if (nextDelegate) { nextDelegate.keyboardFocus(); 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: true 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 onPressAndHold: { // prevent editing if lock layout is enabled if (folio.FolioSettings.lockLayout) return; 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 (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: true 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); } onPressAndHold: { 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 (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.visible = true } } Timer { id: thumbnailHideTimer interval: 300 onTriggered: { thumbnailPopup.visible = false root.hoveredTaskIndex = -1 } } Window { id: thumbnailPopup property var targetDelegate: null property string windowTitle: "" property string windowUuid: "" property bool popupHovered: false function open() { visible = true } function close() { visible = false } readonly property bool opened: visible flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus color: "transparent" width: Kirigami.Units.gridUnit * 16 height: popupContent.implicitHeight + 2 * Kirigami.Units.smallSpacing // Position above the hovered dock icon, in global coordinates x: { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) return Math.max(0, delegateGlobal.x + (targetDelegate.width - width) / 2) } y: { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) return delegateGlobal.y - height - Kirigami.Units.smallSpacing } onVisibleChanged: { if (!visible) { windowUuid = "" targetDelegate = null } } Rectangle { anchors.fill: parent color: Kirigami.Theme.backgroundColor 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 MouseArea { id: popupHoverArea anchors.fill: parent hoverEnabled: true onContainsMouseChanged: { thumbnailPopup.popupHovered = containsMouse if (containsMouse) { thumbnailHideTimer.stop() } else if (root.hoveredTaskIndex < 0) { thumbnailHideTimer.restart() } } onClicked: { if (thumbnailPopup.targetDelegate) { tasksModel.requestActivate( tasksModel.makeModelIndex(thumbnailPopup.targetDelegate.index)) thumbnailPopup.close() } } Column { id: popupContent anchors.fill: parent anchors.margins: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing Item { width: parent.width height: width * 9 / 16 Loader { id: pipeWireLoader active: thumbnailPopup.visible && thumbnailPopup.windowUuid !== "" anchors.fill: parent sourceComponent: PipeWireThumbnail { windowUuid: thumbnailPopup.windowUuid } } Kirigami.Icon { anchors.centerIn: parent width: Kirigami.Units.iconSizes.huge height: width source: thumbnailPopup.targetDelegate ? thumbnailPopup.targetDelegate.model.decoration : "" visible: !pipeWireLoader.item || !pipeWireLoader.item.hasThumbnail } } PC3.Label { width: parent.width text: thumbnailPopup.windowTitle elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter maximumLineCount: 1 } } } } } Repeater { id: taskRepeater model: root.convergenceMode ? tasksModel : null delegate: Item { id: taskDelegate required property int index required property var model readonly property bool isLocationBottom: folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Bottom // Position after all favourites property double fromCenterValue: (repeater.count + taskDelegate.index) - (root.totalItemCount / 2) Behavior on fromCenterValue { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad; } } readonly property int centerPosition: (isLocationBottom ? root.dockCellWidth : root.dockCellHeight) * fromCenterValue x: isLocationBottom ? centerPosition + root.dockCenterX : (parent.width - width) / 2 y: isLocationBottom ? (parent.height - height) / 2 : parent.height / 2 - centerPosition - root.dockCellHeight implicitWidth: root.dockCellWidth implicitHeight: root.dockCellHeight width: root.dockCellWidth height: root.dockCellHeight // Hover highlight background Rectangle { anchors.fill: parent radius: Kirigami.Units.cornerRadius color: taskMouseArea.containsPress ? Qt.rgba(255, 255, 255, 0.2) : (taskMouseArea.containsMouse ? Qt.rgba(255, 255, 255, 0.1) : "transparent") } // Task icon Kirigami.Icon { anchors.centerIn: parent width: Math.min(parent.width, parent.height) * 0.6 height: width source: taskDelegate.model.decoration active: taskMouseArea.containsMouse } // 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 / 2 spacing: Kirigami.Units.smallSpacing / 2 Repeater { model: root.windowCountForTask(taskDelegate.index) Rectangle { width: Kirigami.Units.smallSpacing * 1.5 height: width radius: width / 2 color: Kirigami.Theme.highlightColor opacity: taskDelegate.model.IsActive === true ? 1.0 : 0.4 } } } // Click to activate, hover for thumbnail preview MouseArea { id: taskMouseArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (mouse) => { if (mouse.button === Qt.RightButton) { thumbnailPopup.close() thumbnailShowTimer.stop() taskContextMenu.open(); } else { thumbnailPopup.close() tasksModel.requestActivate(tasksModel.makeModelIndex(taskDelegate.index)); } } onContainsMouseChanged: { if (containsMouse) { thumbnailHideTimer.stop() thumbnailPopup.targetDelegate = taskDelegate thumbnailPopup.windowTitle = taskDelegate.model.display || "" var winIds = taskDelegate.model.WinIdList thumbnailPopup.windowUuid = (winIds && winIds.length > 0) ? winIds[0] : "" 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 property string taskStorageId: { var id = taskDelegate.model.AppId || "" if (id && !id.endsWith(".desktop")) id += ".desktop" return id } PC3.MenuItem { icon.name: "window-pin" text: i18n("Pin to Dock") visible: taskContextMenu.taskStorageId !== "" && !folio.FavouritesModel.containsApplication(taskContextMenu.taskStorageId) enabled: !folio.FolioSettings.lockLayout onClicked: folio.FavouritesModel.addApplication(taskContextMenu.taskStorageId) } PC3.MenuItem { icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize" text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize") 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") onClicked: tasksModel.requestToggleMaximized(tasksModel.makeModelIndex(taskDelegate.index)) } PC3.MenuItem { icon.name: "window-close" text: i18n("Close") onClicked: tasksModel.requestClose(tasksModel.makeModelIndex(taskDelegate.index)) } } } } }