From 97abf33597105c9a3149c9a8a4b4d50ce1df36f4 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 27 May 2026 15:48:09 +0200 Subject: [PATCH] Add dynamic tiling layout menu Expose layout requests and state through shell settings so Folio can offer alternatives for the current tiled workspace. Keep KWin as the owner of layout changes, snapshot request serials so requests are not dropped, and hide the edge menu when fewer than two windows are tiled. --- .../mobileshellsettings.cpp | 65 ++++ .../shellsettingsplugin/mobileshellsettings.h | 14 + containments/homescreens/folio/CMakeLists.txt | 1 + .../folio/qml/DynamicTilingLayoutMenu.qml | 298 ++++++++++++++++++ containments/homescreens/folio/qml/main.qml | 176 ++++++++++- .../scripts/shift-tiling/contents/ui/main.qml | 93 +++++- tests/check-dynamic-tiles-motion.sh | 17 + 7 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 containments/homescreens/folio/qml/DynamicTilingLayoutMenu.qml 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