shift-shell/containments/homescreens/folio/qml/HomeScreenPage.qml
Marco Allegretti a37734b74a Move homescreens to shared motion
Use Motion wrappers and state layers across Folio and Halcyon delegates, drawers, folders, resize handles, and dock feedback. Also align Folio edit/drop highlights with theme colors instead of fixed white overlays.
2026-05-21 11:13:36 +02:00

524 lines
21 KiB
QML

// SPDX-FileCopyrightText: 2023 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
import QtQuick
import QtQuick.Window
import QtQuick.Layouts
import QtQuick.Effects
import org.kde.plasma.components 3.0 as PC3
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell as MobileShell
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
import org.kde.kirigami as Kirigami
import "./delegate"
import "./private"
Item {
id: root
property Folio.HomeScreen folio
property MobileShell.MaskManager maskManager
property int pageNum
property var pageModel
property var homeScreen
readonly property color editFeedbackColor: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.24)
readonly property color dropFeedbackColor: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.28)
onActiveFocusChanged: {
if (activeFocus) {
// Focus on first delegate when this page is focused
let firstDelegate = findFirstDelegate();
if (!firstDelegate) {
return;
}
firstDelegate.keyboardFocus();
}
}
// Returns the first delegate on the page, or null if none exist
function findFirstDelegate() {
let firstDelegate = delegateRepeater.itemAt(0);
if (!firstDelegate) {
return null;
}
for (let i = 0; i < delegateRepeater.count; ++i) {
let delegate = delegateRepeater.itemAt(i);
const isAppOrFolder = delegate.pageDelegate.type === Folio.FolioDelegate.Application || delegate.pageDelegate.type === Folio.FolioDelegate.Folder;
// If it's on an earlier row, or on the same row but earlier column
if (isAppOrFolder
&& (delegate.row < firstDelegate.row
|| (delegate.column < firstDelegate.column
&& delegate.row === firstDelegate.row))) {
firstDelegate = delegate;
}
}
return firstDelegate;
}
// Returns the next application/folder delegate on the page from the given delegate
// in a certain direction, or null if none exist.
function findNextAppDelegate(delegate, direction: MobileShell.Direction) {
let dx = 0;
let dy = 0;
switch (direction) {
case MobileShell.Direction.Up:
dy = -1;
break;
case MobileShell.Direction.Down:
dy = 1;
break;
case MobileShell.Direction.Left:
dx = -1;
break;
case MobileShell.Direction.Right:
dx = 1;
break;
}
let x = delegate.column + dx;
let y = delegate.row + dy;
// Loop in direction to find delegate
while (x >= 0 && x < folio.HomeScreenState.pageColumns && y >= 0 && y < folio.HomeScreenState.pageRows) {
for (let i = 0; i < delegateRepeater.count; ++i) {
let delegate = delegateRepeater.itemAt(i);
if (delegate.row === y && delegate.column === x
&& (delegate.pageDelegate.type === Folio.FolioDelegate.Application
|| delegate.pageDelegate.type === Folio.FolioDelegate.Folder)) {
return delegate;
}
}
x += dx;
y += dy;
}
return null;
}
MobileShell.HapticsEffect {
id: haptics
}
// background when in settings view (for rearranging pages)
Rectangle {
id: settingsViewBackground
anchors.fill: parent
color: root.editFeedbackColor
opacity: folio.HomeScreenState.settingsOpenProgress
radius: Kirigami.Units.largeSpacing
}
// square that shows when hovering over a spot to drop a delegate on
PlaceholderDelegate {
id: dragDropFeedback
folio: root.folio
width: folio.HomeScreenState.pageCellWidth
height: folio.HomeScreenState.pageCellHeight
property var dropPosition: folio.HomeScreenState.dragState.candidateDropPosition
property var dropDelegate: folio.HomeScreenState.dragState.dropDelegate
property bool dropDelegateIsWidget: dropDelegate && dropDelegate.type === Folio.FolioDelegate.Widget
// only show if it is an empty spot on this page
visible: folio.HomeScreenState.isDraggingDelegate &&
dropPosition.location === Folio.DelegateDragPosition.Pages &&
dropPosition.page === root.pageNum &&
!dropDelegateIsWidget &&
folio.HomeScreenState.getPageDelegateAt(root.pageNum, dropPosition.pageRow, dropPosition.pageColumn) === null
x: dropPosition.pageColumn * folio.HomeScreenState.pageCellWidth
y: dropPosition.pageRow * folio.HomeScreenState.pageCellHeight
}
// square that shows when a widget hovers over a spot to drop a delegate on
Rectangle {
id: widgetDragDropFeedback
width: (dropDelegateIsWidget ? dropDelegate.widget.gridWidth : 0) * folio.HomeScreenState.pageCellWidth
height: (dropDelegateIsWidget ? dropDelegate.widget.gridHeight : 0) * folio.HomeScreenState.pageCellHeight
property var dropPosition: folio.HomeScreenState.dragState.candidateDropPosition
property var dropDelegate: folio.HomeScreenState.dragState.dropDelegate
property bool dropDelegateIsWidget: dropDelegate && dropDelegate.type === Folio.FolioDelegate.Widget
// only show if the widget can be placed here
visible: folio.HomeScreenState.isDraggingDelegate &&
dropPosition.location === Folio.DelegateDragPosition.Pages &&
dropPosition.page === root.pageNum &&
dropDelegateIsWidget &&
pageModel.canAddDelegate(dropPosition.pageRow, dropPosition.pageColumn, dropDelegate)
radius: Kirigami.Units.cornerRadius
color: root.dropFeedbackColor
x: dropPosition.pageColumn * folio.HomeScreenState.pageCellWidth
y: dropPosition.pageRow * folio.HomeScreenState.pageCellHeight
layer.enabled: true
layer.effect: DelegateShadow {}
}
// repeater of all delegates in the page
Repeater {
id: delegateRepeater
model: root.pageModel
delegate: Item {
id: delegate
property Folio.FolioPageDelegate pageDelegate: model.delegate
property int row: pageDelegate.row
property int column: pageDelegate.column
property var dragState: folio.HomeScreenState.dragState
property bool isDropPositionThis: dragState.candidateDropPosition.location === Folio.DelegateDragPosition.Pages &&
dragState.candidateDropPosition.page === root.pageNum &&
dragState.candidateDropPosition.pageRow === delegate.pageDelegate.row &&
dragState.candidateDropPosition.pageColumn === delegate.pageDelegate.column
property bool isAppHoveredOver: folio.HomeScreenState.isDraggingDelegate &&
dragState.dropDelegate &&
dragState.dropDelegate.type === Folio.FolioDelegate.Application &&
isDropPositionThis
implicitWidth: loader.item ? loader.item.implicitWidth : 0
implicitHeight: loader.item ? loader.item.implicitHeight : 0
width: loader.item ? loader.item.width : 0
height: loader.item ? loader.item.height : 0
x: column * folio.HomeScreenState.pageCellWidth
y: row * folio.HomeScreenState.pageCellHeight
visible: row >= 0 && row < folio.HomeScreenState.pageRows &&
column >= 0 && column < folio.HomeScreenState.pageColumns
// called when we want to delete this delegate
function removeSelf() {
// remove from model
root.pageModel.removeDelegate(delegate.row, delegate.column);
// TODO: this doesn't work, because the removeDelegate calls removes this entire object and the calls fail
// // delete empty pages at the end, and snap position to page that exists
// folio.PageListModel.deleteEmptyPagesAtEnd();
// folio.HomeScreenState.snapPage();
}
// Keyboard navigation between delegates
Keys.onPressed: (event) => {
let direction = MobileShell.Direction.Up;
switch (event.key) {
case Qt.Key_Up:
direction = MobileShell.Direction.Up;
break;
case Qt.Key_Down:
direction = MobileShell.Direction.Down;
break;
case Qt.Key_Left:
direction = MobileShell.Direction.Left;
break;
case Qt.Key_Right:
direction = MobileShell.Direction.Right;
break;
default:
return;
}
let nextDelegate = root.findNextAppDelegate(delegate, direction);
if (nextDelegate) {
nextDelegate.keyboardFocus();
event.accepted = true;
}
}
function keyboardFocus() {
if (loader.item) {
loader.item.keyboardFocus();
}
}
Loader {
id: loader
anchors.top: parent.top
anchors.left: parent.left
sourceComponent: {
if (delegate.pageDelegate.type === Folio.FolioDelegate.Application) {
return appComponent;
} else if (delegate.pageDelegate.type === Folio.FolioDelegate.Folder) {
return folderComponent;
} else if (delegate.pageDelegate.type === Folio.FolioDelegate.Widget) {
return widgetComponent;
} else {
return noneComponent;
}
}
}
Component {
id: noneComponent
Item {}
}
Component {
id: appComponent
AppDelegate {
id: appDelegate
folio: root.folio
maskManager: root.maskManager
name: folio.FolioSettings.showPagesAppLabels ? delegate.pageDelegate.application.name : ""
application: delegate.pageDelegate.application
turnToFolder: delegate.isAppHoveredOver
turnToFolderAnimEnabled: folio.HomeScreenState.isDraggingDelegate
implicitWidth: folio.HomeScreenState.pageCellWidth
implicitHeight: folio.HomeScreenState.pageCellHeight
width: folio.HomeScreenState.pageCellWidth
height: folio.HomeScreenState.pageCellHeight
// 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.pageDelegate, appDelegate.delegateItem);
folio.HomeScreenState.startDelegatePageDrag(
mappedCoords.x,
mappedCoords.y,
appDelegate.pressPosition.x,
appDelegate.pressPosition.y,
root.pageNum,
delegate.pageDelegate.row,
delegate.pageDelegate.column
);
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
// close menu when drag starts
Connections {
target: folio.HomeScreenState
function onDelegateDragStarted() {
contextMenu.close();
}
}
actions: [
Kirigami.Action {
icon.name: "emblem-favorite"
text: i18n("Remove")
enabled: !folio.FolioSettings.lockLayout
onTriggered: delegate.removeSelf()
}
]
}
}
}
Component {
id: folderComponent
AppFolderDelegate {
id: appFolderDelegate
folio: root.folio
maskManager: root.maskManager
name: folio.FolioSettings.showPagesAppLabels ? delegate.pageDelegate.folder.name : ""
folder: delegate.pageDelegate.folder
implicitWidth: folio.HomeScreenState.pageCellWidth
implicitHeight: folio.HomeScreenState.pageCellHeight
width: folio.HomeScreenState.pageCellWidth
height: folio.HomeScreenState.pageCellHeight
// 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)
// don't show label in drag and drop mode
labelOpacity: delegate.opacity
appHoveredOver: delegate.isAppHoveredOver
onAfterClickAnimation: {
const pos = homeScreen.prepareFolderOpen(appFolderDelegate.contentItem);
folio.HomeScreenState.openFolder(pos.x, pos.y, folder);
}
onPressAndHold: {
// prevent editing if lock layout is enabled
if (folio.FolioSettings.lockLayout) return;
let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.pageDelegate, appFolderDelegate.delegateItem);
folio.HomeScreenState.startDelegatePageDrag(
mappedCoords.x,
mappedCoords.y,
appFolderDelegate.pressPosition.x,
appFolderDelegate.pressPosition.y,
root.pageNum,
delegate.pageDelegate.row,
delegate.pageDelegate.column
);
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
// close menu when drag starts
Connections {
target: folio.HomeScreenState
function onDelegateDragStarted() {
contextMenu.close();
}
}
actions: [
Kirigami.Action {
icon.name: "emblem-favorite"
text: i18n("Remove")
enabled: !folio.FolioSettings.lockLayout
onTriggered: deleteDialog.open()
}
]
ConfirmDeleteFolderDialogLoader {
id: deleteDialog
parent: root.homeScreen
onAccepted: delegate.removeSelf()
}
}
}
}
Component {
id: widgetComponent
WidgetDelegate {
id: widgetDelegate
folio: root.folio
// don't reparent applet if the drop animation is running to this delegate
// background: there is only one "visual" instance of the widget, once this delegate loads
// it will reparent it to here (but we don't want it to happen while the drop animation is running)
property bool suppressAppletReparent: (root.homeScreen.currentlyDraggedWidget === delegate.pageDelegate.widget)
&& delegate.isDropPositionThis
visible: !suppressAppletReparent
widget: suppressAppletReparent ? null : delegate.pageDelegate.widget
onStartEditMode: (pressPoint) => {
// prevent editing if lock layout is enabled
if (folio.FolioSettings.lockLayout) return;
let mappedCoords = root.homeScreen.prepareStartDelegateDrag(delegate.pageDelegate, widgetDelegate);
folio.HomeScreenState.startDelegatePageDrag(
mappedCoords.x,
mappedCoords.y,
pressPoint.x - mappedCoords.x,
pressPoint.y - mappedCoords.y,
root.pageNum,
delegate.pageDelegate.row,
delegate.pageDelegate.column
);
widgetConfig.startOpen();
haptics.buttonVibrate();
}
onPressReleased: {
// cancel the event if the delegate is not dragged
if (folio.HomeScreenState.swipeState === Folio.HomeScreenState.AwaitingDraggingDelegate) {
folio.HomeScreenState.cancelDelegateDrag();
widgetConfig.fullyOpen();
}
}
layer.enabled: widgetDelegate.editMode && folio.FolioSettings.lockLayout === false
layer.effect: DarkenEffect {}
PC3.ToolTip {
visible: widgetDelegate.editMode && pressed
text: i18n("Release to configure, drag to move")
}
WidgetDelegateConfig {
id: widgetConfig
folio: root.folio
homeScreen: root.homeScreen
pageModel: root.pageModel
pageDelegate: delegate.pageDelegate
widget: delegate.pageDelegate.widget
pageNum: root.pageNum
row: delegate.row
column: delegate.column
widgetWidth: widgetDelegate.widgetWidth
widgetHeight: widgetDelegate.widgetHeight
widgetX: delegate.x + root.anchors.leftMargin + root.homeScreen.leftMargin
widgetY: delegate.y + root.anchors.topMargin + root.homeScreen.topMargin
topWidgetBackgroundPadding: widgetDelegate.topWidgetBackgroundPadding
bottomWidgetBackgroundPadding: widgetDelegate.bottomWidgetBackgroundPadding
leftWidgetBackgroundPadding: widgetDelegate.leftWidgetBackgroundPadding
rightWidgetBackgroundPadding: widgetDelegate.rightWidgetBackgroundPadding
anchors.fill: parent
onRemoveRequested: {
if (widget.applet) {
widget.destroyApplet();
}
delegate.removeSelf();
}
onClosed: widgetDelegate.editMode = false
}
}
}
}
}
}