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.
This commit is contained in:
Marco Allegretti 2026-05-27 15:48:09 +02:00
parent 1702027f7e
commit 97abf33597
7 changed files with 662 additions and 2 deletions

View file

@ -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};

View file

@ -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();

View file

@ -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

View file

@ -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 }
}
}
}

View file

@ -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()
}
}

View file

@ -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();
}
}

View file

@ -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'