diff --git a/components/shellsettingsplugin/mobileshellsettings.cpp b/components/shellsettingsplugin/mobileshellsettings.cpp index d1343daf..f4fddfd1 100644 --- a/components/shellsettingsplugin/mobileshellsettings.cpp +++ b/components/shellsettingsplugin/mobileshellsettings.cpp @@ -49,6 +49,8 @@ MobileShellSettings::MobileShellSettings(QObject *parent) Q_EMIT dynamicTilingEnabledChanged(); Q_EMIT dynamicTilingWindowRequestChanged(); Q_EMIT dynamicTilingWindowStateChanged(); + Q_EMIT dynamicTilingLayoutRequestChanged(); + Q_EMIT dynamicTilingLayoutStateChanged(); Q_EMIT snapLayoutsEnabledChanged(); Q_EMIT allowLogoutChanged(); } @@ -371,6 +373,69 @@ void MobileShellSettings::reportDynamicTilingWindowState(const QStringList &maxi Q_EMIT dynamicTilingWindowStateChanged(); } +QString MobileShellSettings::dynamicTilingLayoutRequestMode() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("dynamicTilingLayoutRequestMode", QString{}); +} + +int MobileShellSettings::dynamicTilingLayoutRequestSerial() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("dynamicTilingLayoutRequestSerial", 0); +} + +void MobileShellSettings::requestDynamicTilingLayoutMode(const QString &mode) +{ + if (mode.isEmpty()) { + return; + } + + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + const int serial = group.readEntry("dynamicTilingLayoutRequestSerial", 0) + 1; + group.writeEntry("dynamicTilingLayoutRequestMode", mode, KConfigGroup::Notify); + group.writeEntry("dynamicTilingLayoutRequestSerial", serial, KConfigGroup::Notify); + m_config->sync(); + + Q_EMIT dynamicTilingLayoutRequestChanged(); +} + +QString MobileShellSettings::dynamicTilingLayoutMode() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("dynamicTilingLayoutMode", QString{}); +} + +int MobileShellSettings::dynamicTilingLayoutWindowCount() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("dynamicTilingLayoutWindowCount", 0); +} + +int MobileShellSettings::dynamicTilingLayoutStateSerial() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("dynamicTilingLayoutStateSerial", 0); +} + +void MobileShellSettings::reportDynamicTilingLayoutState(const QString &mode, int windowCount) +{ + const int normalizedWindowCount = windowCount < 0 ? 0 : windowCount; + + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + if (group.readEntry("dynamicTilingLayoutMode", QString{}) == mode && group.readEntry("dynamicTilingLayoutWindowCount", 0) == normalizedWindowCount) { + return; + } + + const int serial = group.readEntry("dynamicTilingLayoutStateSerial", 0) + 1; + group.writeEntry("dynamicTilingLayoutMode", mode, KConfigGroup::Notify); + group.writeEntry("dynamicTilingLayoutWindowCount", normalizedWindowCount, KConfigGroup::Notify); + group.writeEntry("dynamicTilingLayoutStateSerial", serial, KConfigGroup::Notify); + m_config->sync(); + + Q_EMIT dynamicTilingLayoutStateChanged(); +} + bool MobileShellSettings::snapLayoutsEnabled() const { auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; diff --git a/components/shellsettingsplugin/mobileshellsettings.h b/components/shellsettingsplugin/mobileshellsettings.h index 5bcbf66b..b6133b96 100644 --- a/components/shellsettingsplugin/mobileshellsettings.h +++ b/components/shellsettingsplugin/mobileshellsettings.h @@ -65,6 +65,11 @@ class MobileShellSettings : public QObject Q_PROPERTY(int dynamicTilingWindowRequestSerial READ dynamicTilingWindowRequestSerial NOTIFY dynamicTilingWindowRequestChanged) Q_PROPERTY(QStringList dynamicTilingMaximizedWindowIds READ dynamicTilingMaximizedWindowIds NOTIFY dynamicTilingWindowStateChanged) Q_PROPERTY(int dynamicTilingWindowStateSerial READ dynamicTilingWindowStateSerial NOTIFY dynamicTilingWindowStateChanged) + Q_PROPERTY(QString dynamicTilingLayoutRequestMode READ dynamicTilingLayoutRequestMode NOTIFY dynamicTilingLayoutRequestChanged) + Q_PROPERTY(int dynamicTilingLayoutRequestSerial READ dynamicTilingLayoutRequestSerial NOTIFY dynamicTilingLayoutRequestChanged) + Q_PROPERTY(QString dynamicTilingLayoutMode READ dynamicTilingLayoutMode NOTIFY dynamicTilingLayoutStateChanged) + Q_PROPERTY(int dynamicTilingLayoutWindowCount READ dynamicTilingLayoutWindowCount NOTIFY dynamicTilingLayoutStateChanged) + Q_PROPERTY(int dynamicTilingLayoutStateSerial READ dynamicTilingLayoutStateSerial NOTIFY dynamicTilingLayoutStateChanged) // Snap layout picker — only meaningful in convergence mode when dynamic tiling is off. Q_PROPERTY(bool snapLayoutsEnabled READ snapLayoutsEnabled WRITE setSnapLayoutsEnabled NOTIFY snapLayoutsEnabledChanged) @@ -302,6 +307,13 @@ public: int dynamicTilingWindowStateSerial() const; Q_INVOKABLE bool isDynamicTilingWindowMaximized(const QString &windowId) const; Q_INVOKABLE void reportDynamicTilingWindowState(const QStringList &maximizedWindowIds); + QString dynamicTilingLayoutRequestMode() const; + int dynamicTilingLayoutRequestSerial() const; + Q_INVOKABLE void requestDynamicTilingLayoutMode(const QString &mode); + QString dynamicTilingLayoutMode() const; + int dynamicTilingLayoutWindowCount() const; + int dynamicTilingLayoutStateSerial() const; + Q_INVOKABLE void reportDynamicTilingLayoutState(const QString &mode, int windowCount); /** * Whether the SHIFT snap layout picker is enabled. @@ -362,6 +374,8 @@ Q_SIGNALS: void dynamicTilingEnabledChanged(); void dynamicTilingWindowRequestChanged(); void dynamicTilingWindowStateChanged(); + void dynamicTilingLayoutRequestChanged(); + void dynamicTilingLayoutStateChanged(); void snapLayoutsEnabledChanged(); void allowLogoutChanged(); void lockscreenLeftButtonActionChanged(); diff --git a/containments/homescreens/folio/CMakeLists.txt b/containments/homescreens/folio/CMakeLists.txt index 96405e36..23a51485 100644 --- a/containments/homescreens/folio/CMakeLists.txt +++ b/containments/homescreens/folio/CMakeLists.txt @@ -11,6 +11,7 @@ plasma_add_applet(org.kde.plasma.mobile.homescreen.folio qml/CategoryPanel.qml qml/DelegateDragItem.qml qml/DelegateDropArea.qml + qml/DynamicTilingLayoutMenu.qml qml/FavouritesBar.qml qml/FolderView.qml qml/FolderViewTitle.qml diff --git a/containments/homescreens/folio/qml/DynamicTilingLayoutMenu.qml b/containments/homescreens/folio/qml/DynamicTilingLayoutMenu.qml new file mode 100644 index 00000000..2edc0255 --- /dev/null +++ b/containments/homescreens/folio/qml/DynamicTilingLayoutMenu.qml @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes 1.8 + +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PlasmaComponents +import org.kde.plasma.private.mobileshell as MobileShell + +Item { + id: root + + property int windowCount: 0 + property string currentMode: "" + property color surfaceColor: Kirigami.Theme.backgroundColor + property int animationDuration: Kirigami.Units.shortDuration + property real maxHeight: 0 + + signal layoutModeRequested(string mode) + signal dismissRequested() + + readonly property int clampedWindowCount: clampedLayoutWindowCount(windowCount) + readonly property var layoutOptions: layoutOptionsForWindowCount(clampedWindowCount) + readonly property int optionCount: layoutOptions.length + readonly property real rowHeight: Math.max(Kirigami.Units.gridUnit * 2.4, + Kirigami.Units.iconSizes.medium + Kirigami.Units.smallSpacing * 2) + readonly property real naturalHeight: Kirigami.Units.gridUnit * 2.2 + + Math.max(1, optionCount) * rowHeight + + Kirigami.Units.smallSpacing * 3 + readonly property real preferredHeight: maxHeight > 0 ? Math.min(naturalHeight, maxHeight) : naturalHeight + readonly property real cornerRadius: Math.min(MobileShell.Constants.convergenceWorkspaceFrameRadius, height * 0.24) + + clip: true + + function clampedLayoutWindowCount(windowCount) { + const count = Math.round(Number(windowCount) || 0) + return Math.max(0, Math.min(4, count)) + } + + function linearLayoutZones(windowCount, orientation) { + const count = Math.max(1, clampedLayoutWindowCount(windowCount)) + let zones = [] + for (let i = 0; i < count; i++) { + if (orientation === "horizontal") { + zones.push({ x: 0, y: i / count, w: 1, h: 1 / count }) + } else { + zones.push({ x: i / count, y: 0, w: 1 / count, h: 1 }) + } + } + return zones + } + + function masterLayoutZones(windowCount) { + const count = clampedLayoutWindowCount(windowCount) + if (count <= 2) { + return linearLayoutZones(Math.max(1, count), "vertical") + } + + let zones = [{ x: 0, y: 0, w: 0.58, h: 1 }] + const stackCount = count - 1 + for (let i = 0; i < stackCount; i++) { + zones.push({ x: 0.58, y: i / stackCount, w: 0.42, h: 1 / stackCount }) + } + return zones + } + + function layoutOptionsForWindowCount(windowCount) { + const count = clampedLayoutWindowCount(windowCount) + if (count < 2) { + return [] + } + + if (count === 2) { + return [ + { + mode: "columns", + selectedModes: ["master", "columns"], + name: i18n("Side by Side"), + summary: i18n("2 columns"), + zones: linearLayoutZones(count, "vertical") + }, + { + mode: "rows", + selectedModes: ["rows"], + name: i18n("Stacked"), + summary: i18n("2 rows"), + zones: linearLayoutZones(count, "horizontal") + } + ] + } + + return [ + { + mode: "master", + selectedModes: ["master"], + name: i18n("Master Stack"), + summary: i18n("1 + %1 stack", count - 1), + zones: masterLayoutZones(count) + }, + { + mode: "columns", + selectedModes: ["columns"], + name: i18n("Columns"), + summary: i18n("%1 columns", count), + zones: linearLayoutZones(count, "vertical") + }, + { + mode: "rows", + selectedModes: ["rows"], + name: i18n("Rows"), + summary: i18n("%1 rows", count), + zones: linearLayoutZones(count, "horizontal") + } + ] + } + + function emptyLayoutSummary() { + return clampedWindowCount === 1 ? i18n("1 window") : i18n("0 windows") + } + + Behavior on opacity { + MobileShell.MotionNumberAnimation { + type: MobileShell.Motion.EffectsFast + duration: root.animationDuration + } + } + + Shape { + anchors.fill: parent + + ShapePath { + fillColor: root.surfaceColor + strokeWidth: 0 + + startX: root.width + startY: 0 + PathLine { x: root.cornerRadius; y: 0 } + PathArc { + x: 0 + y: root.cornerRadius + radiusX: root.cornerRadius + radiusY: root.cornerRadius + direction: PathArc.Counterclockwise + } + PathLine { x: 0; y: root.height - root.cornerRadius } + PathArc { + x: root.cornerRadius + y: root.height + radiusX: root.cornerRadius + radiusY: root.cornerRadius + direction: PathArc.Counterclockwise + } + PathLine { x: root.width; y: root.height } + PathLine { x: root.width; y: 0 } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents.Label { + Layout.fillWidth: true + text: i18n("Tiling Layout") + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + } + + Repeater { + model: root.layoutOptions + + delegate: MouseArea { + id: optionButton + + required property var modelData + + Layout.fillWidth: true + Layout.preferredHeight: root.rowHeight + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + readonly property bool selected: modelData.selectedModes.indexOf(root.currentMode) >= 0 + + onClicked: { + if (!selected) { + root.layoutModeRequested(modelData.mode) + } + root.dismissRequested() + } + + Rectangle { + anchors.fill: parent + radius: Kirigami.Units.cornerRadius + color: optionButton.selected + ? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.24) + : optionButton.containsMouse + ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.08) + : "transparent" + border.width: optionButton.selected ? 1 : 0 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.5) + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Kirigami.Units.smallSpacing + anchors.rightMargin: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + + Rectangle { + id: layoutPreviewFrame + + Layout.preferredWidth: Kirigami.Units.gridUnit * 2.5 + Layout.preferredHeight: Kirigami.Units.gridUnit * 1.65 + radius: Kirigami.Units.cornerRadius + color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.06) + border.width: 1 + border.color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.18) + + Repeater { + model: optionButton.modelData.zones + + delegate: Rectangle { + required property var modelData + + x: Math.round(layoutPreviewFrame.width * modelData.x) + Kirigami.Units.smallSpacing / 2 + y: Math.round(layoutPreviewFrame.height * modelData.y) + Kirigami.Units.smallSpacing / 2 + width: Math.max(1, Math.round(layoutPreviewFrame.width * modelData.w) - Kirigami.Units.smallSpacing) + height: Math.max(1, Math.round(layoutPreviewFrame.height * modelData.h) - Kirigami.Units.smallSpacing) + radius: Math.max(1, Kirigami.Units.cornerRadius - 1) + color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.58) + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + PlasmaComponents.Label { + Layout.fillWidth: true + text: optionButton.modelData.name + elide: Text.ElideRight + maximumLineCount: 1 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: optionButton.modelData.summary + opacity: 0.62 + font: Kirigami.Theme.smallFont + elide: Text.ElideRight + maximumLineCount: 1 + } + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.optionCount === 0 + spacing: Kirigami.Units.smallSpacing + + Item { Layout.fillHeight: true } + + PlasmaComponents.Label { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: i18n("No Alternatives") + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: root.emptyLayoutSummary() + opacity: 0.62 + font: Kirigami.Theme.smallFont + elide: Text.ElideRight + maximumLineCount: 1 + } + + Item { Layout.fillHeight: true } + } + } +} \ No newline at end of file diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index e95a99ea..1cce5531 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -306,14 +306,23 @@ ContainmentItem { readonly property real workAreaWidth: Math.max(0, width - frameThickness * 2) readonly property real workAreaHeight: Math.max(0, height - topBarHeight - dockHeight - frameThickness * 2) readonly property real leftEdgeHotzoneWidth: Math.max(frameThickness, Math.round(Kirigami.Units.gridUnit * 0.7)) + readonly property real rightEdgeHotzoneWidth: leftEdgeHotzoneWidth readonly property real leftLauncherWidth: Math.min(Kirigami.Units.gridUnit * 22, width * 0.42) readonly property real leftLauncherHeight: Math.min(Kirigami.Units.gridUnit * 16, workAreaHeight * 0.66) readonly property bool leftLauncherEnabled: root.folio.HomeScreenState.appDrawerOpenProgress <= 0 - readonly property real leftFrameBulgeIdleDepth: Math.max(frameThickness * 1.2, Kirigami.Units.gridUnit * 0.35) + readonly property real layoutMenuWidth: Math.min(Kirigami.Units.gridUnit * 16, width * 0.34) + readonly property int layoutMenuWindowCount: Math.max(0, ShellSettings.Settings.dynamicTilingLayoutWindowCount) + readonly property bool layoutMenuEnabled: ShellSettings.Settings.dynamicTilingEnabled + && layoutMenuWindowCount >= 2 + && root.folio.HomeScreenState.appDrawerOpenProgress <= 0 + readonly property real leftFrameBulgeIdleDepth: Math.max(frameThickness * 0.45, Kirigami.Units.gridUnit * 0.16) readonly property real leftFrameBulgeHoverDepth: 0 property real leftFrameBulgeDepth: !leftLauncherEnabled || leftLauncherOpen || leftEdgeHovered ? leftFrameBulgeHoverDepth : leftFrameBulgeIdleDepth + property real rightFrameBulgeDepth: !layoutMenuEnabled || layoutMenuOpen || rightEdgeHovered + ? leftFrameBulgeHoverDepth + : leftFrameBulgeIdleDepth // Long, thin thickening of the lower-left workspace wall. Vertical // tangents at all three anchors keep the curve smooth as it blends // into the straight wall above and below. @@ -326,6 +335,13 @@ ContainmentItem { // Bezier control-handle length along the vertical tangent at each // anchor. ~0.55 of the half-length gives a clean, taut oval profile. readonly property real leftFrameBulgeTangent: leftFrameBulgeHalfLength * 0.55 + readonly property real rightFrameBulgeEffectiveDepth: Math.max(rightFrameBulgeDepth, 0.01) + readonly property real rightFrameBulgeApexX: workAreaX + workAreaWidth - rightFrameBulgeEffectiveDepth + readonly property real rightFrameBulgeHalfLength: leftFrameBulgeHalfLength + readonly property real rightFrameBulgeApexY: leftFrameBulgeApexY + readonly property real rightFrameBulgeEdgeTopY: rightFrameBulgeApexY - rightFrameBulgeHalfLength + readonly property real rightFrameBulgeEdgeBottomY: rightFrameBulgeApexY + rightFrameBulgeHalfLength + readonly property real rightFrameBulgeTangent: rightFrameBulgeHalfLength * 0.55 readonly property color chromeColor: Kirigami.Theme.backgroundColor readonly property color edgeColor: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.1) readonly property int dockAnimationDuration: MobileShell.Motion.duration(MobileShell.Motion.SpatialDefault) @@ -339,6 +355,9 @@ ContainmentItem { property bool leftEdgeHovered: false property bool leftLauncherHovered: false property bool leftLauncherOpen: false + property bool rightEdgeHovered: false + property bool layoutMenuHovered: false + property bool layoutMenuOpen: false readonly property bool shouldHide: ShellSettings.Settings.autoHidePanelsEnabled && windowMaximizedTracker.showingWindow && !hoverRevealing @@ -349,12 +368,21 @@ ContainmentItem { function updateInputRegion() { const topBarRegion = Qt.rect(0, 0, width, topBarHitHeight) const leftEdgeRegion = Qt.rect(0, topBarHitHeight, leftEdgeHotzoneWidth, Math.max(0, height - topBarHitHeight - dockHeight)) + const rightEdgeRegion = Qt.rect(width - rightEdgeHotzoneWidth, topBarHitHeight, rightEdgeHotzoneWidth, Math.max(0, height - topBarHitHeight - dockHeight)) const leftLauncherRegion = Qt.rect(0, Math.max(0, height - dockHeight - leftLauncherHeight), leftLauncherWidth, leftLauncherHeight) + const layoutMenuRegion = Qt.rect(rightLayoutMenu.x, + rightLayoutMenu.y, + rightLayoutMenu.width, + rightLayoutMenu.height) let regions = [topBarRegion, leftEdgeRegion] + if (layoutMenuEnabled) { + regions.push(rightEdgeRegion) + } + if (shouldHide && dockOffset >= dockHeight) { regions.push(Qt.rect(0, height - revealStripHeight, width, revealStripHeight)) } else { @@ -365,6 +393,10 @@ ContainmentItem { regions.push(leftLauncherRegion) } + if (layoutMenuEnabled && layoutMenuOpen) { + regions.push(layoutMenuRegion) + } + MobileShell.ShellUtil.setInputRegions(convergenceChrome, regions) } @@ -397,6 +429,25 @@ ContainmentItem { inputRegionTimer.restart() } + function refreshLayoutMenuVisibility() { + if (!layoutMenuEnabled) { + layoutMenuCloseTimer.stop() + rightEdgeHovered = false + layoutMenuHovered = false + layoutMenuOpen = false + inputRegionTimer.restart() + return + } + + if (rightEdgeHovered || layoutMenuHovered) { + layoutMenuCloseTimer.stop() + layoutMenuOpen = true + } else { + layoutMenuCloseTimer.restart() + } + inputRegionTimer.restart() + } + function updateLeftLauncherHoverState(pointerX, pointerY, withinWindow) { const insideEdge = withinWindow && pointerX >= 0 @@ -421,6 +472,30 @@ ContainmentItem { refreshLeftLauncherVisibility() } + function updateLayoutMenuHoverState(pointerX, pointerY, withinWindow) { + const insideEdge = layoutMenuEnabled + && withinWindow + && pointerX >= (width - rightEdgeHotzoneWidth) + && pointerX <= width + && pointerY >= topBarHitHeight + && pointerY <= (height - dockHeight) + + const insideMenu = withinWindow + && layoutMenuOpen + && pointerX >= rightLayoutMenu.x + && pointerX <= width + && pointerY >= rightLayoutMenu.y + && pointerY <= (rightLayoutMenu.y + rightLayoutMenu.height) + + if (rightEdgeHovered !== insideEdge) { + rightEdgeHovered = insideEdge + } + if (layoutMenuHovered !== insideMenu) { + layoutMenuHovered = insideMenu + } + refreshLayoutMenuVisibility() + } + onActiveChanged: { hoverRevealTimer.stop() hoverRevealing = false @@ -438,6 +513,7 @@ ContainmentItem { inputRegionTimer.restart() } onLeftLauncherEnabledChanged: refreshLeftLauncherVisibility() + onLayoutMenuEnabledChanged: refreshLayoutMenuVisibility() // Narrow the input region to a strip at the screen edge when hidden // so that app controls near the bottom edge are not accidentally @@ -484,6 +560,20 @@ ContainmentItem { } } + Timer { + id: layoutMenuCloseTimer + interval: Kirigami.Units.shortDuration + repeat: false + onTriggered: { + if (!convergenceChrome.rightEdgeHovered + && !convergenceChrome.layoutMenuHovered + && convergenceChrome.layoutMenuOpen) { + convergenceChrome.layoutMenuOpen = false + inputRegionTimer.restart() + } + } + } + Behavior on dockOffset { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.SpatialDefault @@ -498,6 +588,13 @@ ContainmentItem { } } + Behavior on rightFrameBulgeDepth { + MobileShell.MotionNumberAnimation { + type: MobileShell.Motion.SpatialDefault + duration: root.shortAnimationDuration + } + } + Rectangle { id: topBarSurface anchors.top: parent.top @@ -534,6 +631,23 @@ ContainmentItem { PathMove { x: convergenceChrome.workAreaX + convergenceChrome.frameRadius; y: convergenceChrome.workAreaY } PathLine { x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth - convergenceChrome.frameRadius; y: convergenceChrome.workAreaY } PathArc { x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth; y: convergenceChrome.workAreaY + convergenceChrome.frameRadius; radiusX: convergenceChrome.frameRadius; radiusY: convergenceChrome.frameRadius } + PathLine { x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth; y: convergenceChrome.rightFrameBulgeEdgeTopY } + PathCubic { + x: convergenceChrome.rightFrameBulgeApexX + y: convergenceChrome.rightFrameBulgeApexY + control1X: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth + control1Y: convergenceChrome.rightFrameBulgeEdgeTopY + convergenceChrome.rightFrameBulgeTangent + control2X: convergenceChrome.rightFrameBulgeApexX + control2Y: convergenceChrome.rightFrameBulgeApexY - convergenceChrome.rightFrameBulgeTangent + } + PathCubic { + x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth + y: convergenceChrome.rightFrameBulgeEdgeBottomY + control1X: convergenceChrome.rightFrameBulgeApexX + control1Y: convergenceChrome.rightFrameBulgeApexY + convergenceChrome.rightFrameBulgeTangent + control2X: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth + control2Y: convergenceChrome.rightFrameBulgeEdgeBottomY - convergenceChrome.rightFrameBulgeTangent + } PathLine { x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth; y: convergenceChrome.workAreaY + convergenceChrome.workAreaHeight - convergenceChrome.frameRadius } PathArc { x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth - convergenceChrome.frameRadius; y: convergenceChrome.workAreaY + convergenceChrome.workAreaHeight; radiusX: convergenceChrome.frameRadius; radiusY: convergenceChrome.frameRadius } PathLine { x: convergenceChrome.workAreaX + convergenceChrome.frameRadius; y: convergenceChrome.workAreaY + convergenceChrome.workAreaHeight } @@ -580,6 +694,23 @@ ContainmentItem { controlX: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth controlY: convergenceChrome.workAreaY } + PathLine { x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth; y: convergenceChrome.rightFrameBulgeEdgeTopY } + PathCubic { + x: convergenceChrome.rightFrameBulgeApexX + y: convergenceChrome.rightFrameBulgeApexY + control1X: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth + control1Y: convergenceChrome.rightFrameBulgeEdgeTopY + convergenceChrome.rightFrameBulgeTangent + control2X: convergenceChrome.rightFrameBulgeApexX + control2Y: convergenceChrome.rightFrameBulgeApexY - convergenceChrome.rightFrameBulgeTangent + } + PathCubic { + x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth + y: convergenceChrome.rightFrameBulgeEdgeBottomY + control1X: convergenceChrome.rightFrameBulgeApexX + control1Y: convergenceChrome.rightFrameBulgeApexY + convergenceChrome.rightFrameBulgeTangent + control2X: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth + control2Y: convergenceChrome.rightFrameBulgeEdgeBottomY - convergenceChrome.rightFrameBulgeTangent + } PathLine { x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth; y: convergenceChrome.workAreaY + convergenceChrome.workAreaHeight - convergenceChrome.frameRadius } PathQuad { x: convergenceChrome.workAreaX + convergenceChrome.workAreaWidth - convergenceChrome.frameRadius @@ -672,6 +803,15 @@ ContainmentItem { width: convergenceChrome.leftEdgeHotzoneWidth } + Item { + id: rightEdgeStrip + + anchors.right: parent.right + anchors.top: topBarSurface.bottom + anchors.bottom: dockSurface.top + width: convergenceChrome.layoutMenuEnabled ? convergenceChrome.rightEdgeHotzoneWidth : 0 + } + MouseArea { id: leftLauncherPointerTracker @@ -682,9 +822,43 @@ ContainmentItem { onPositionChanged: (mouse) => { convergenceChrome.updateLeftLauncherHoverState(mouse.x, mouse.y, true) + convergenceChrome.updateLayoutMenuHoverState(mouse.x, mouse.y, true) } onExited: { convergenceChrome.updateLeftLauncherHoverState(-1, -1, false) + convergenceChrome.updateLayoutMenuHoverState(-1, -1, false) + } + } + + DynamicTilingLayoutMenu { + id: rightLayoutMenu + + width: convergenceChrome.layoutMenuWidth + height: preferredHeight + x: convergenceChrome.width - width + y: convergenceChrome.height - convergenceChrome.dockHeight - height + visible: convergenceChrome.layoutMenuOpen + opacity: convergenceChrome.layoutMenuOpen ? 1 : 0 + maxHeight: convergenceChrome.workAreaHeight * 0.5 + windowCount: convergenceChrome.layoutMenuWindowCount + currentMode: ShellSettings.Settings.dynamicTilingLayoutMode + surfaceColor: convergenceChrome.chromeColor + animationDuration: root.shortAnimationDuration + + transform: Translate { + y: convergenceChrome.layoutMenuOpen ? 0 : Kirigami.Units.gridUnit + x: convergenceChrome.layoutMenuOpen ? 0 : rightLayoutMenu.width - convergenceChrome.rightEdgeHotzoneWidth + } + + onLayoutModeRequested: (mode) => { + if (ShellSettings.Settings.requestDynamicTilingLayoutMode !== undefined) { + ShellSettings.Settings.requestDynamicTilingLayoutMode(mode) + } + } + + onDismissRequested: { + convergenceChrome.layoutMenuOpen = false + inputRegionTimer.restart() } } diff --git a/kwin/scripts/shift-tiling/contents/ui/main.qml b/kwin/scripts/shift-tiling/contents/ui/main.qml index 469a0819..d4acffe1 100644 --- a/kwin/scripts/shift-tiling/contents/ui/main.qml +++ b/kwin/scripts/shift-tiling/contents/ui/main.qml @@ -71,8 +71,10 @@ Item { // Values keep callback references so KWin script reloads can disconnect them. property var dragConnectedWindows: ({}) - property int lastWindowRequestSerial: ShellSettings.Settings.dynamicTilingWindowRequestSerial + property int lastWindowRequestSerial: -1 property string lastPublishedMaximizedWindowIds: "__unpublished__" + property int lastLayoutRequestSerial: -1 + property string lastPublishedLayoutState: "__unpublished__" // Drag state. // @@ -504,6 +506,7 @@ Item { const focused = Object.assign({}, lastFocusedWindowKeys); delete focused[outputName]; lastFocusedWindowKeys = focused; + publishDynamicTilingLayoutState(); } function clearDisplacedWindowOwnersForLayout(outputName) { @@ -654,6 +657,8 @@ Item { for (let i = 0; i < cleanupLayouts.length; i++) { cleanupEmptyLayout(cleanupLayouts[i]); } + + publishDynamicTilingLayoutState(); } function makeLeaf(win) { @@ -842,6 +847,15 @@ Item { const win = KWinComponents.Workspace.activeWindow; const activeName = layoutKeyForWindow(win); if (activeName !== "") return activeName; + const desktop = KWinComponents.Workspace.currentDesktop; + const screens = KWinComponents.Workspace.screens; + if (desktop && screens && screens.length > 0) { + for (let i = 0; i < screens.length; i++) { + const currentDesktopName = layoutKeyFor(screens[i].name, desktop); + if (screenLayouts[currentDesktopName]) return currentDesktopName; + } + return layoutKeyFor(screens[0].name, desktop); + } for (const name in screenLayouts) { return name; } @@ -863,6 +877,21 @@ Item { markLayoutChanged(transaction, outputName); applyLayoutTransaction(transaction); } + publishDynamicTilingLayoutState(); + } + + function setLayoutMode(outputName, mode) { + if (!outputName || layoutModes.indexOf(mode) < 0) return; + setLayoutModeForScreen(outputName, mode); + + const windows = orderedWindowsForScreen(outputName); + if (windows.length > 0) { + setStableLayout(outputName, windows); + const transaction = createLayoutTransaction(); + markLayoutChanged(transaction, outputName); + applyLayoutTransaction(transaction); + } + publishDynamicTilingLayoutState(); } function containsLeaf(node, key) { @@ -1281,6 +1310,37 @@ Item { } } + function publishDynamicTilingLayoutState() { + if (!isConvergence()) { + const disabledState = "|0"; + if (disabledState === lastPublishedLayoutState) return; + + lastPublishedLayoutState = disabledState; + if (ShellSettings.Settings.reportDynamicTilingLayoutState !== undefined) { + ShellSettings.Settings.reportDynamicTilingLayoutState("", 0); + } + return; + } + + const outputName = outputNameForActiveWindow(); + const mode = outputName !== "" ? layoutModeForScreen(outputName) : ""; + const windowCount = outputName !== "" ? windowCountForLayout(outputName) : 0; + const serialized = mode + "|" + windowCount; + if (serialized === lastPublishedLayoutState) return; + + lastPublishedLayoutState = serialized; + if (ShellSettings.Settings.reportDynamicTilingLayoutState !== undefined) { + ShellSettings.Settings.reportDynamicTilingLayoutState(mode, windowCount); + } + } + + function clearDynamicTilingLayoutState() { + lastPublishedLayoutState = "__unpublished__"; + if (ShellSettings.Settings.reportDynamicTilingLayoutState !== undefined) { + ShellSettings.Settings.reportDynamicTilingLayoutState("", 0); + } + } + function maximizedLayoutNameForWindow(win) { const key = windowKey(win); if (!key) return ""; @@ -1682,6 +1742,19 @@ Item { } } + function handleLayoutModeRequest() { + const serial = ShellSettings.Settings.dynamicTilingLayoutRequestSerial; + if (serial === lastLayoutRequestSerial) return; + lastLayoutRequestSerial = serial; + + if (!isConvergence()) return; + + const mode = ShellSettings.Settings.dynamicTilingLayoutRequestMode; + if (layoutModes.indexOf(mode) < 0) return; + + setLayoutMode(outputNameForActiveWindow(), mode); + } + function promoteWindow(win) { if (!isTileable(win)) return; @@ -1771,10 +1844,12 @@ Item { function onActiveWindowChanged() { root.rememberFocusedWindow(KWinComponents.Workspace.activeWindow); + root.publishDynamicTilingLayoutState(); } function onCurrentDesktopChanged() { root.retileCurrentDesktopLayouts(); + root.publishDynamicTilingLayoutState(); } function onScreensChanged() { @@ -1788,6 +1863,7 @@ Item { } root.scheduleRetileAll(); + root.publishDynamicTilingLayoutState(); } } @@ -1801,6 +1877,7 @@ Item { for (let i = 0; i < wins.length; i++) { adoptWindow(wins[i]); } + publishDynamicTilingLayoutState(); } else { // Clear all tiles — the convergentwindows script will re-maximize restoreAllMaximizedLayouts(); @@ -1809,6 +1886,7 @@ Item { screenLayoutModes = {}; lastFocusedWindowKeys = {}; maximizedLayouts = {}; + clearDynamicTilingLayoutState(); } } @@ -1820,11 +1898,13 @@ Item { screenLayoutModes = {}; lastFocusedWindowKeys = {}; maximizedLayouts = {}; + clearDynamicTilingLayoutState(); } else if (isConvergence()) { const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { adoptWindow(wins[i]); } + publishDynamicTilingLayoutState(); } } @@ -1834,6 +1914,7 @@ Item { for (let i = 0; i < wins.length; i++) { adoptWindow(wins[i]); } + publishDynamicTilingLayoutState(); } else { // Tiling turned off — leave windows where they are. restoreAllMaximizedLayouts(); @@ -1842,12 +1923,17 @@ Item { screenLayoutModes = {}; lastFocusedWindowKeys = {}; maximizedLayouts = {}; + clearDynamicTilingLayoutState(); } } function onDynamicTilingWindowRequestChanged() { root.handleWindowTilingRequest(); } + + function onDynamicTilingLayoutRequestChanged() { + root.handleLayoutModeRequest(); + } } // ── Drag handlers ───────────────────────────────────────────────────── @@ -2165,6 +2251,9 @@ Item { // ── Component setup ─────────────────────────────────────────────────── Component.onCompleted: { + lastWindowRequestSerial = ShellSettings.Settings.dynamicTilingWindowRequestSerial; + lastLayoutRequestSerial = ShellSettings.Settings.dynamicTilingLayoutRequestSerial; + // Connect to existing windows const wins = KWinComponents.Workspace.windows; for (let i = 0; i < wins.length; i++) { @@ -2172,10 +2261,12 @@ Item { } rememberFocusedWindow(KWinComponents.Workspace.activeWindow); publishDynamicTilingWindowState(); + publishDynamicTilingLayoutState(); } Component.onDestruction: { disconnectDragHandlers(); clearDynamicTilingWindowState(); + clearDynamicTilingLayoutState(); } } diff --git a/tests/check-dynamic-tiles-motion.sh b/tests/check-dynamic-tiles-motion.sh index e47c451b..2f16840e 100644 --- a/tests/check-dynamic-tiles-motion.sh +++ b/tests/check-dynamic-tiles-motion.sh @@ -12,6 +12,7 @@ effects_cmake="$repo_root/kwin/effects/CMakeLists.txt" tiling_script="$repo_root/kwin/scripts/shift-tiling/contents/ui/main.qml" decoration_qml="$repo_root/kwin/decorations/org.shift.decoration/contents/ui/main.qml" running_apps_panel="$repo_root/containments/homescreens/folio/qml/RunningAppsPanel.qml" +folio_main="$repo_root/containments/homescreens/folio/qml/main.qml" env_config="$repo_root/envmanager/config.h" require_line() { @@ -60,6 +61,10 @@ require_line "$effect_qml" "showPreview(\"restore\"" require_line "$tiling_script" "readonly property int maxWindowsPerPage: 4" require_line "$tiling_script" "readonly property real stablePrimaryRatio: 0.58" require_line "$tiling_script" "readonly property var layoutModes: [\"master\", \"columns\", \"rows\"]" +require_line "$tiling_script" "property int lastWindowRequestSerial: -1" +require_line "$tiling_script" "property int lastLayoutRequestSerial: -1" +require_line "$tiling_script" "lastWindowRequestSerial = ShellSettings.Settings.dynamicTilingWindowRequestSerial" +require_line "$tiling_script" "lastLayoutRequestSerial = ShellSettings.Settings.dynamicTilingLayoutRequestSerial" require_line "$tiling_script" "function desktopKey(desktop)" require_line "$tiling_script" "function desktopForWindow(win)" require_line "$tiling_script" "function normalizeWindowDesktopScope(win)" @@ -126,6 +131,16 @@ require_line "$decoration_qml" "borders.bottom = normalCornerRadius;" require_line "$decoration_qml" "PathArc { x: root.width - root.cornerRadius; y: root.height; radiusX: root.cornerRadius; radiusY: root.cornerRadius }" require_line "$decoration_qml" "PathArc { x: 0; y: root.height - root.cornerRadius; radiusX: root.cornerRadius; radiusY: root.cornerRadius }" +require_line "$folio_main" "readonly property int layoutMenuWindowCount: Math.max(0, ShellSettings.Settings.dynamicTilingLayoutWindowCount)" +require_line "$folio_main" "&& layoutMenuWindowCount >= 2" +require_line "$folio_main" "if (layoutMenuEnabled) {" +require_line "$folio_main" "regions.push(rightEdgeRegion)" +require_line "$folio_main" "if (layoutMenuEnabled && layoutMenuOpen)" +require_line "$folio_main" "rightEdgeHovered = false" +require_line "$folio_main" "layoutMenuHovered = false" +require_line "$folio_main" "const insideEdge = layoutMenuEnabled" +require_line "$folio_main" "width: convergenceChrome.layoutMenuEnabled ? convergenceChrome.rightEdgeHotzoneWidth : 0" + running_panel_group_disabled_count="$(grep -F "groupMode: TaskManager.TasksModel.GroupDisabled" "$running_apps_panel" | wc -l)" if [[ "$running_panel_group_disabled_count" -ne 2 ]]; then echo "Expected the Folio Running panel to disable grouping for both task models; found $running_panel_group_disabled_count" >&2 @@ -155,6 +170,8 @@ reject_line "$tiling_script" "function tileInsertDirection(cursor, rect)" reject_line "$tiling_script" "showDragOutline(\"insert\"" reject_line "$tiling_script" "KWinComponents.Workspace.showOutline(dragOutlineRect)" reject_line "$tiling_script" "KWinComponents.Workspace.hideOutline()" +reject_line "$tiling_script" "property int lastWindowRequestSerial: ShellSettings.Settings.dynamicTilingWindowRequestSerial" +reject_line "$tiling_script" "property int lastLayoutRequestSerial: ShellSettings.Settings.dynamicTilingLayoutRequestSerial" reject_line "$effect_qml" "effect.visible = true" printf '%s\n' 'dynamic-tiles-motion-ok' \ No newline at end of file