shell: Rework configuration implementation

This reworks the implementation of the applet/containment configuration
so that it is more optimized for the mobile experience and fixes lateral
navigation (between categories).

Changes:
- Always show a list of category modules (switching from the navigation
  tab bar) in order to support more modules at once
- Split the wallpaper and containment switching view into two modules
- Add a close button at the top
- Add an animation when the window opens and closes
- Refactor the code so that it is clear which files are imported by the
  shell, and which are implementation details
This commit is contained in:
Devin Lin 2025-10-05 00:46:01 -04:00
parent c8479ca16d
commit a39401100f
9 changed files with 330 additions and 343 deletions

View file

@ -12,14 +12,18 @@ import org.kde.kirigami 2.19 as Kirigami
import org.kde.plasma.configuration 2.0
import org.kde.kitemmodels 1.0 as KItemModels
Rectangle {
import './private'
/**
* This component is loaded by libplasma when the "configuration window" is requested for an applet.
*/
Item {
id: root
LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft
LayoutMirroring.childrenInherit: true
color: "transparent"
//BEGIN properties
// Properties filled in or needed by libplasma
property bool isContainment: false
property alias app: appLoader.item
@ -47,105 +51,46 @@ Rectangle {
//END model
//BEGIN functions
function saveConfig() {
if (app.pageStack.currentItem.saveConfig) {
app.pageStack.currentItem.saveConfig()
}
for (var key in Plasmoid.configuration) {
if (app.pageStack.currentItem["cfg_"+key] !== undefined) {
Plasmoid.configuration[key] = app.pageStack.currentItem["cfg_"+key]
}
}
}
function configurationHasChanged() {
for (var key in Plasmoid.configuration) {
if (app.pageStack.currentItem["cfg_"+key] !== undefined) {
//for objects == doesn't work
if (typeof Plasmoid.configuration[key] == 'object') {
for (var i in Plasmoid.configuration[key]) {
if (Plasmoid.configuration[key][i] != app.pageStack.currentItem["cfg_"+key][i]) {
return true;
}
}
return false;
} else if (app.pageStack.currentItem["cfg_"+key] != Plasmoid.configuration[key]) {
return true;
}
}
}
return false;
}
function settingValueChanged() {
if (app.pageStack.currentItem.saveConfig !== undefined) {
app.pageStack.currentItem.saveConfig();
} else {
root.saveConfig();
}
}
function pushReplace(item, config) {
let page;
if (app.pageStack.depth === 0) {
page = app.pageStack.push(item, config);
} else {
page = app.pageStack.replace(item, config);
}
app.currentConfigPage = page;
}
function open(item) {
app.isAboutPage = false;
if (item.source) {
app.isAboutPage = item.source === "AboutPlugin.qml";
pushReplace(Qt.resolvedUrl("ConfigurationAppletPage.qml"), {configItem: item, title: item.name});
app.pageStack.push(Qt.resolvedUrl("private/ConfigurationAppletPage.qml"), {configItem: item, title: item.name});
} else if (item.kcm) {
pushReplace(configurationKcmPageComponent, {kcm: item.kcm, internalPage: item.kcm.mainUi});
} else {
app.pageStack.pop();
app.pageStack.push(configurationKcmPageComponent, {kcm: item.kcm, internalPage: item.kcm.mainUi});
}
}
//END functions
//BEGIN connections
Connections {
target: root.Window.window
function onVisibleChanged() {
if (root.Window.window.visible) {
root.Window.window.showMaximized();
Binding {
// Window bindings
root.Window.window.flags: Qt.FramelessWindowHint
root.Window.window.visibility: Window.Maximized
}
}
}
//END connections
//BEGIN UI components
Component {
id: configurationKcmPageComponent
ConfigurationKcmPage {}
}
Component {
id: configListPageComponent
ConfigListPage {
onRequestOpen: (delegate) => root.open(delegate);
}
}
Loader {
id: appLoader
anchors.fill: parent
asynchronous: true
active: root.loadApp
// Load first page
onLoaded: {
// if we are a containment then the first item will be ConfigurationContainmentAppearance
// if the applet does not have own configs then the first item will be Shortcuts
if (isContainment || !configDialog.configModel || configDialog.configModel.count === 0) {
root.open(root.globalConfigModel.get(0))
} else {
root.open(configDialog.configModel.get(0))
}
// Push config list page
app.pageStack.push(configListPageComponent, {
title: i18nc("The title of the applet configuration window", "Configure %1", Plasmoid.metaData.name),
model1: configDialogFilterModel,
model2: root.globalConfigModel
});
root.appLoaded();
}
@ -154,90 +99,50 @@ Rectangle {
id: app
anchors.fill: parent
// animation on show
opacity: 0
NumberAnimation on opacity {
to: 1
running: true
duration: Kirigami.Units.longDuration
easing.type: Easing.InOutQuad
pageStack {
globalToolBar {
canContainHandles: true
style: Kirigami.ApplicationHeaderStyle.ToolBar
showNavigationButtons: Kirigami.ApplicationHeaderStyle.ShowBackButton
}
popHiddenPages: true
columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn
}
pageStack.globalToolBar.canContainHandles: true
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
pageStack.globalToolBar.showNavigationButtons: Kirigami.ApplicationHeaderStyle.ShowBackButton;
property var currentConfigPage: null
property bool isAboutPage: false
// pop pages when not in use
// Implement open/close animation
Connections {
target: app.pageStack
function onCurrentIndexChanged() {
// wait for animation to finish before popping pages
timer.restart();
target: root.Window.window
function onVisibleChanged() {
if (visible) {
opacityAnim.to = 1;
opacityAnim.restart();
}
}
Timer {
id: timer
interval: 300
onTriggered: {
let currentIndex = app.pageStack.currentIndex;
while (app.pageStack.depth > (currentIndex + 1) && currentIndex >= 0) {
app.pageStack.pop();
function onClosing(close) {
if (app.opacity !== 0) {
close.accepted = false;
opacityAnim.to = 0;
opacityAnim.restart();
}
}
}
footer: Kirigami.NavigationTabBar {
id: footerBar
visible: count > 1
height: visible ? implicitHeight : 0
Repeater {
model: root.isContainment ? globalConfigModel : undefined
delegate: configCategoryDelegate
}
Repeater {
model: configDialogFilterModel
delegate: configCategoryDelegate
}
Repeater {
model: !root.isContainment ? globalConfigModel : undefined
delegate: configCategoryDelegate
}
}
opacity: 0
scale: 0.7 + 0.3 * app.opacity
Component {
id: configCategoryDelegate
Kirigami.NavigationTabButton {
icon.name: model.icon
text: model.name
width: footerBar.buttonWidth
QQC2.ButtonGroup.group: footerBar.tabGroup
onClicked: {
if (checked) {
root.open(model);
}
}
checked: {
if (app.pageStack.currentItem) {
if (model.kcm && app.pageStack.currentItem.kcm) {
return model.kcm == app.pageStack.currentItem.kcm;
} else if (app.pageStack.currentItem.configItem) {
return model.source == app.pageStack.currentItem.configItem.source;
} else {
return app.pageStack.currentItem.source == Qt.resolvedUrl(model.source);
}
}
return false;
NumberAnimation on opacity {
id: opacityAnim
duration: Kirigami.Units.longDuration
easing.type: Easing.OutCubic
onFinished: {
if (app.opacity === 0) {
root.Window.window.close();
}
}
}
}
}
//END UI components
}

View file

@ -1,89 +0,0 @@
// SPDX-FileCopyrightText: 2020 Nicolas Fella <nicolas.fella@gmx.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.0
import org.kde.plasma.plasmoid
import org.kde.kirigami 2.10 as Kirigami
Kirigami.ScrollablePage {
id: root
title: configItem.name
required property var configItem
signal settingValueChanged()
onSettingValueChanged: saveConfig() // we save config immediately on mobile
function saveConfig() {
for (let key in Plasmoid.configuration) {
if (loader.item["cfg_" + key] != undefined) {
Plasmoid.configuration[key] = loader.item["cfg_" + key]
}
}
// For ConfigurationContainmentActions.qml
if (loader.item.hasOwnProperty("saveConfig")) {
loader.item.saveConfig()
}
}
implicitHeight: loader.height
padding: Kirigami.Units.largeSpacing
bottomPadding: 0
Loader {
id: loader
width: parent.width
// HACK the height of the loader is based on the implicitHeight of the content.
// Unfortunately not all content items have a sensible implicitHeight.
// If it is zero fall back to the height of its children
// Also make it at least as high as the page itself. Some existing configs assume they fill the whole space
// TODO KF6 clean this up by making all configs based on SimpleKCM/ScrollViewKCM/GridViewKCM
height: {
if (item) {
return Math.max(root.availableHeight, item.implicitHeight ? item.implicitHeight : item.childrenRect.height);
} else {
return root.availableHeight;
}
}
Component.onCompleted: {
const plasmoidConfig = Plasmoid.configuration
const props = {}
for (let key in plasmoidConfig) {
props["cfg_" + key] = Plasmoid.configuration[key]
}
setSource(configItem.source, props)
}
onLoaded: {
const plasmoidConfig = Plasmoid.configuration;
for (let key in plasmoidConfig) {
const changedSignal = item["cfg_" + key + "Changed"]
if (changedSignal) {
changedSignal.connect(root.settingValueChanged)
}
}
const configurationChangedSignal = item.configurationChanged
if (configurationChangedSignal) {
configurationChangedSignal.connect(root.settingValueChanged)
}
var unsavedChangesChangedSignal = item.unsavedChangesChanged
if (unsavedChangesChangedSignal) {
unsavedChangesChangedSignal.connect( () => {
if (item.unsavedChanges) {
root.settingValueChanged()
}
})
}
}
}
}

View file

@ -13,27 +13,29 @@ import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.configuration 2.0
import org.kde.ksvg 1.0 as KSvg
/**
* This component is loaded by libplasma when the "configuration window" is requested for a containment.
*/
AppletConfiguration {
id: root
isContainment: true
loadApp: true
readonly property bool horizontal: root.width > root.height
onAppLoaded: {
app.width = root.width < root.height ? root.width : Math.min(root.width, Math.max(app.implicitWidth, Kirigami.Units.gridUnit * 45));
app.height = Math.min(root.height, Math.max(app.implicitHeight, Kirigami.Units.gridUnit * 29));
}
//BEGIN model
globalConfigModel: globalContainmentConfigModel
ConfigModel {
id: globalContainmentConfigModel
ConfigCategory {
name: i18nd("plasma_shell_org.kde.plasma.desktop", "Wallpaper")
icon: "preferences-desktop-wallpaper"
source: "ConfigurationContainmentAppearance.qml"
name: i18n("Wallpaper")
icon: "viewimage-symbolic"
source: "ChangeWallpaperModule.qml" // This is a relative path from inside private (since loading is invoked from there)
}
ConfigCategory {
name: i18n("Change Homescreen")
icon: "exchange-positions"
source: "ChangeContainmentModule.qml" // This is a relative path from inside private (since loading is invoked from there)
visible: configDialog.containmentPluginsConfigModel.count > 1
}
}
//END model

View file

@ -0,0 +1,8 @@
<!--
- SPDX-FileCopyrightText: None
- SPDX-License-Identifier: CC0-1.0
-->
This folder contains source files for implementing the configuration window for applets/containments (including homescreens).
[libplasma](https://invent.kde.org/frameworks/libplasma) loads either `AppletConfiguration.qml` or `ContainmentConfiguration.qml` (depending on if its an applet or containment) from this folder when requested by the shell, which in turn initializes the config window.

View file

@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2025 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.plasma.configuration
import org.kde.plasma.plasmoid
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
ColumnLayout {
id: root
property string containmentPlugin: configDialog.containmentPlugin
signal configurationChanged // No need to emit, because containment changes apply immediately
//BEGIN functions
function saveConfig() {
configDialog.containmentPlugin = root.containmentPlugin
}
//END functions
FormCard.FormHeader {
title: i18n("Select Homescreen")
}
FormCard.FormCard {
Repeater {
model: configDialog.containmentPluginsConfigModel
delegate: FormCard.FormRadioDelegate {
enabled: !Plasmoid.immutable
text: model.name
checked: configDialog.containmentPlugin === model.pluginName
// Always restore binding
onCheckedChanged: checked = Qt.binding(() => configDialog.containmentPlugin === model.pluginName);
onClicked: {
if (root.containmentPlugin === model.pluginName) {
return;
}
root.containmentPlugin = model.pluginName;
confirmationDialog.name = model.name;
confirmationDialog.open();
}
}
}
}
Kirigami.PromptDialog {
id: confirmationDialog
standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel
property string name
title: i18n("Change homescreen to %1?", name)
subtitle: i18n("Your current homescreen's settings are saved, and will be restored if you switch back.")
onAccepted: {
root.saveConfig();
close();
}
onRejected: {
root.containmentPlugin = configDialog.containmentPlugin;
}
}
Item { Layout.fillHeight: true }
}

View file

@ -1,8 +1,6 @@
/*
* SPDX-FileCopyrightText: 2013 Marco Martin <mart@kde.org>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
// SPDX-FileCopyrightText: 2013 Marco Martin <mart@kde.org>
// SPDX-FileCopyrightText: 2025 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import org.kde.plasma.configuration 2.0
@ -19,7 +17,6 @@ ColumnLayout {
spacing: 0
property string currentWallpaper: ""
property string containmentPlugin: configDialog.containmentPlugin
signal configurationChanged
//BEGIN functions
@ -34,59 +31,17 @@ ColumnLayout {
}
configDialog.currentWallpaper = root.currentWallpaper;
configDialog.applyWallpaper()
configDialog.containmentPlugin = root.containmentPlugin
}
//END functions
Kirigami.InlineMessage {
Layout.alignment: Qt.AlignTop
visible: Plasmoid.immutable || animating
text: i18nd("plasma_shell_org.kde.plasma.desktop", "Layout changes have been restricted by the system administrator")
showCloseButton: true
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing * 2 // we need this because ColumnLayout's spacing is 0
}
ColumnLayout {
id: generalConfig
spacing: 0
Layout.alignment: Qt.AlignTop
Layout.fillWidth: true
FormCard.FormHeader {
title: i18n("General")
}
Layout.topMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.largeSpacing
FormCard.FormCard {
FormCard.FormComboBoxDelegate {
id: layoutSelectComboBox
enabled: !Plasmoid.immutable
text: i18nd("plasma_shell_org.kde.plasma.desktop", "Homescreen Layout")
description: i18n("The homescreen layout to use.")
visible: model.count > 1 // only show if there are multiple plugins
model: configDialog.containmentPluginsConfigModel
textRole: "name"
valueRole: "pluginName"
currentIndex: determineCurrentIndex()
onCurrentIndexChanged: {
root.containmentPlugin = configDialog.containmentPluginsConfigModel.get(currentIndex).pluginName;
}
function determineCurrentIndex() {
for (var i = 0; i < configDialog.containmentPluginsConfigModel.count; ++i) {
var data = configDialog.containmentPluginsConfigModel.get(i);
if (configDialog.containmentPlugin === data.pluginName) {
return i;
}
}
return -1;
}
}
FormCard.FormDelegateSeparator { above: layoutSelectComboBox; below: wallpaperPluginSelectComboBox }
FormCard.FormComboBoxDelegate {
id: wallpaperPluginSelectComboBox
@ -138,30 +93,6 @@ ColumnLayout {
}
}
ColumnLayout {
id: switchContainmentWarning
Layout.alignment: Qt.AlignTop
Layout.fillWidth: true
visible: configDialog.containmentPlugin !== root.containmentPlugin
QQC2.Label {
Layout.fillWidth: true
text: i18nd("plasma_shell_org.kde.plasma.desktop", "Layout changes must be applied before other changes can be made")
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
}
QQC2.Button {
Layout.alignment: Qt.AlignHCenter
text: i18nd("plasma_shell_org.kde.plasma.desktop", "Apply now")
onClicked: saveConfig()
}
}
Item {
Layout.alignment: Qt.AlignTop
Layout.fillHeight: switchContainmentWarning.visible
visible: switchContainmentWarning.visible
}
Item {
id: emptyConfig
Layout.alignment: Qt.AlignTop
@ -172,11 +103,9 @@ ColumnLayout {
Layout.alignment: Qt.AlignTop
Layout.fillHeight: true
Layout.maximumHeight: root.height - generalConfig.height - 70 // HACK: wallpaper configs seem to go over the provisioned height
Layout.maximumHeight: root.height - generalConfig.height - Kirigami.Units.smallSpacing // HACK: wallpaper configs seem to go over the provisioned height
Layout.fillWidth: true
visible: !switchContainmentWarning.visible
// Bug 360862: if wallpaper has no config, sourceFile will be ""
// so we wouldn't load emptyConfig and break all over the place
// hence set it to some random value initially

View file

@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: 2025 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.plasma.plasmoid
import org.kde.kirigami as Kirigami
import org.kde.plasma.configuration
import org.kde.kitemmodels as KItemModels
Kirigami.ScrollablePage {
id: root
property alias model1: repeater1.model
property alias model2: repeater2.model
topPadding: 0
leftPadding: 0
rightPadding: 0
bottomPadding: 0
titleDelegate: RowLayout {
// Add close button
QQC2.ToolButton {
Layout.leftMargin: -Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing
icon.name: "arrow-left"
onClicked: root.Window.window.close()
}
Kirigami.Heading {
level: 1
text: root.title
}
}
signal requestOpen(var delegate)
ColumnLayout {
spacing: 0
Kirigami.InlineMessage {
Layout.alignment: Qt.AlignTop
visible: Plasmoid.immutable
text: i18n("Layout changes have been restricted by the system administrator")
showCloseButton: true
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing * 2 // we need this because ColumnLayout's spacing is 0
}
Repeater {
id: repeater1
delegate: QQC2.ItemDelegate {
icon.name: model.icon
text: model.name
Layout.fillWidth: true
onClicked: root.requestOpen(model)
}
}
Repeater {
id: repeater2
delegate: QQC2.ItemDelegate {
icon.name: model.icon
text: model.name
Layout.fillWidth: true
onClicked: root.requestOpen(model)
}
}
}
}

View file

@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2020 Nicolas Fella <nicolas.fella@gmx.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.0
import org.kde.plasma.plasmoid
import org.kde.kirigami 2.10 as Kirigami
Kirigami.Page {
id: root
required property var configItem
signal settingValueChanged()
onSettingValueChanged: saveConfig() // we save config immediately on mobile
title: configItem.name
topPadding: 0
leftPadding: 0
rightPadding: 0
bottomPadding: 0
function saveConfig() {
for (let key in Plasmoid.configuration) {
if (loader.item["cfg_" + key] != undefined) {
Plasmoid.configuration[key] = loader.item["cfg_" + key]
}
}
// For ConfigurationContainmentActions.qml
if (loader.item.hasOwnProperty("saveConfig")) {
loader.item.saveConfig()
}
}
data: [
Loader {
id: loader
Component.onCompleted: {
const plasmoidConfig = Plasmoid.configuration
const props = {}
for (let key in plasmoidConfig) {
props["cfg_" + key] = Plasmoid.configuration[key]
}
// Inject configurable config values
setSource(configItem.source, props)
}
onLoaded: {
item.parent = root.contentItem;
item.anchors.fill = root.contentItem;
const plasmoidConfig = Plasmoid.configuration;
for (let key in plasmoidConfig) {
const changedSignal = item["cfg_" + key + "Changed"]
if (changedSignal) {
changedSignal.connect(root.settingValueChanged)
}
}
const configurationChangedSignal = item.configurationChanged
if (configurationChangedSignal) {
configurationChangedSignal.connect(root.settingValueChanged)
}
var unsavedChangesChangedSignal = item.unsavedChangesChanged
if (unsavedChangesChangedSignal) {
unsavedChangesChangedSignal.connect( () => {
if (item.unsavedChanges) {
root.settingValueChanged()
}
})
}
}
}
]
}

View file

@ -16,11 +16,13 @@ Kirigami.Page {
signal settingValueChanged()
onSettingValueChanged: saveConfig(); // we save config immediately on mobile
title: kcm.name
title: internalPage.title ? internalPage.title : kcm.name
topPadding: 0
leftPadding: 0
rightPadding: 0
bottomPadding: 0
flickable: internalPage.flickable
actions: [
internalPage.actions.main,
@ -38,7 +40,7 @@ Kirigami.Page {
}
Component.onCompleted: {
kcm.load()
kcm.load();
}
function saveConfig() {
@ -48,14 +50,21 @@ Kirigami.Page {
data: [
Connections {
target: kcm
onPagePushed: {
function onPagePushed() {
app.pageStack.push(configurationKcmPageComponent.createObject(app.pageStack, {"kcm": kcm, "internalPage": page}));
}
onPageRemoved: app.pageStack.pop();
function onPageRemoved() {
app.pageStack.pop();
}
function onNeedsSaveChanged() {
if (kcm.needsSave) {
container.settingValueChanged()
}
}
},
Connections {
target: app.pageStack
onPageRemoved: {
function onPageRemoved() {
if (kcm.needsSave) {
kcm.save()
}
@ -65,12 +74,4 @@ Kirigami.Page {
}
}
]
Connections {
target: kcm
function onNeedsSaveChanged() {
if (kcm.needsSave) {
container.settingValueChanged()
}
}
}
}