shift-shell/containments/homescreens/folio/qml/DynamicTilingLayoutMenu.qml
Marco Allegretti 97abf33597 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.
2026-05-27 15:48:09 +02:00

298 lines
No EOL
11 KiB
QML

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