diff options
Diffstat (limited to 'modules/controlcenter/appearance/AppearancePane.qml')
| -rw-r--r-- | modules/controlcenter/appearance/AppearancePane.qml | 2455 |
1 files changed, 2455 insertions, 0 deletions
diff --git a/modules/controlcenter/appearance/AppearancePane.qml b/modules/controlcenter/appearance/AppearancePane.qml new file mode 100644 index 0000000..891f64b --- /dev/null +++ b/modules/controlcenter/appearance/AppearancePane.qml @@ -0,0 +1,2455 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../../launcher/services" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.components.images +import qs.services +import qs.config +import qs.utils +import Caelestia.Models +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + + required property Session session + + // Appearance settings + property real animDurationsScale: Config.appearance.anim.durations.scale ?? 1 + property string fontFamilyMaterial: Config.appearance.font.family.material ?? "Material Symbols Rounded" + property string fontFamilyMono: Config.appearance.font.family.mono ?? "CaskaydiaCove NF" + property string fontFamilySans: Config.appearance.font.family.sans ?? "Rubik" + property real fontSizeScale: Config.appearance.font.size.scale ?? 1 + property real paddingScale: Config.appearance.padding.scale ?? 1 + property real roundingScale: Config.appearance.rounding.scale ?? 1 + property real spacingScale: Config.appearance.spacing.scale ?? 1 + property bool transparencyEnabled: Config.appearance.transparency.enabled ?? false + property real transparencyBase: Config.appearance.transparency.base ?? 0.85 + property real transparencyLayers: Config.appearance.transparency.layers ?? 0.4 + property real borderRounding: Config.border.rounding ?? 1 + property real borderThickness: Config.border.thickness ?? 1 + + // Background settings + property bool desktopClockEnabled: Config.background.desktopClock.enabled ?? false + property bool backgroundEnabled: Config.background.enabled ?? true + property bool visualiserEnabled: Config.background.visualiser.enabled ?? false + property bool visualiserAutoHide: Config.background.visualiser.autoHide ?? true + property real visualiserRounding: Config.background.visualiser.rounding ?? 1 + property real visualiserSpacing: Config.background.visualiser.spacing ?? 1 + + anchors.fill: parent + + spacing: 0 + + + function saveConfig() { + Config.appearance.anim.durations.scale = root.animDurationsScale; + + Config.appearance.font.family.material = root.fontFamilyMaterial; + Config.appearance.font.family.mono = root.fontFamilyMono; + Config.appearance.font.family.sans = root.fontFamilySans; + Config.appearance.font.size.scale = root.fontSizeScale; + + Config.appearance.padding.scale = root.paddingScale; + Config.appearance.rounding.scale = root.roundingScale; + Config.appearance.spacing.scale = root.spacingScale; + + Config.appearance.transparency.enabled = root.transparencyEnabled; + Config.appearance.transparency.base = root.transparencyBase; + Config.appearance.transparency.layers = root.transparencyLayers; + + Config.background.desktopClock.enabled = root.desktopClockEnabled; + Config.background.enabled = root.backgroundEnabled; + + Config.background.visualiser.enabled = root.visualiserEnabled; + Config.background.visualiser.autoHide = root.visualiserAutoHide; + Config.background.visualiser.rounding = root.visualiserRounding; + Config.background.visualiser.spacing = root.visualiserSpacing; + + Config.border.rounding = root.borderRounding; + Config.border.thickness = root.borderThickness; + + Config.save(); + } + + Item { + id: leftAppearanceItem + Layout.preferredWidth: Math.floor(parent.width * 0.4) + Layout.minimumWidth: 420 + Layout.fillHeight: true + + ClippingRectangle { + id: leftAppearanceClippingRect + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: 0 + anchors.rightMargin: Appearance.padding.normal / 2 + radius: leftAppearanceBorder.innerRadius + color: "transparent" + + Loader { + id: leftAppearanceLoader + anchors.fill: parent + anchors.margins: Appearance.padding.large + Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + Appearance.padding.normal / 2 + asynchronous: true + sourceComponent: appearanceLeftContentComponent + property var rootPane: root + } + } + + InnerBorder { + id: leftAppearanceBorder + leftThickness: 0 + rightThickness: Appearance.padding.normal / 2 + } + + Component { + id: appearanceLeftContentComponent + + StyledFlickable { + id: sidebarFlickable + readonly property var rootPane: leftAppearanceLoader.rootPane + flickableDirection: Flickable.VerticalFlick + contentHeight: sidebarLayout.height + + + StyledScrollBar.vertical: StyledScrollBar { + flickable: sidebarFlickable + } + + ColumnLayout { + id: sidebarLayout + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.small + + readonly property bool allSectionsExpanded: + themeModeSection.expanded && + colorVariantSection.expanded && + colorSchemeSection.expanded && + animationsSection.expanded && + fontsSection.expanded && + scalesSection.expanded && + transparencySection.expanded && + borderSection.expanded && + backgroundSection.expanded + + RowLayout { + spacing: Appearance.spacing.smaller + + StyledText { + text: qsTr("Appearance") + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + + Item { + Layout.fillWidth: true + } + + IconButton { + icon: sidebarLayout.allSectionsExpanded ? "unfold_less" : "unfold_more" + type: IconButton.Text + label.animate: true + onClicked: { + const shouldExpand = !sidebarLayout.allSectionsExpanded; + themeModeSection.expanded = shouldExpand; + colorVariantSection.expanded = shouldExpand; + colorSchemeSection.expanded = shouldExpand; + animationsSection.expanded = shouldExpand; + fontsSection.expanded = shouldExpand; + scalesSection.expanded = shouldExpand; + transparencySection.expanded = shouldExpand; + borderSection.expanded = shouldExpand; + backgroundSection.expanded = shouldExpand; + } + } + } + + CollapsibleSection { + id: themeModeSection + title: qsTr("Theme mode") + description: qsTr("Light or dark theme") + showBackground: true + + SwitchRow { + label: qsTr("Dark mode") + checked: !Colours.currentLight + onToggled: checked => { + Colours.setMode(checked ? "dark" : "light"); + } + } + } + + CollapsibleSection { + id: colorVariantSection + title: qsTr("Color variant") + description: qsTr("Material theme variant") + showBackground: true + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small / 2 + + Repeater { + model: M3Variants.list + + delegate: StyledRect { + required property var modelData + + Layout.fillWidth: true + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, modelData.variant === Schemes.currentVariant ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: modelData.variant === Schemes.currentVariant ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + const variant = modelData.variant; + + // Optimistic update - set immediately for responsive UI + Schemes.currentVariant = variant; + Quickshell.execDetached(["caelestia", "scheme", "set", "-v", variant]); + + // Reload after a delay to confirm changes + Qt.callLater(() => { + reloadTimer.restart(); + }); + } + } + + Timer { + id: reloadTimer + interval: 300 + onTriggered: { + Schemes.reload(); + } + } + + RowLayout { + id: variantRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + MaterialIcon { + text: modelData.icon + font.pointSize: Appearance.font.size.large + fill: modelData.variant === Schemes.currentVariant ? 1 : 0 + } + + StyledText { + Layout.fillWidth: true + text: modelData.name + font.weight: modelData.variant === Schemes.currentVariant ? 500 : 400 + } + + MaterialIcon { + visible: modelData.variant === Schemes.currentVariant + text: "check" + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.large + } + } + + implicitHeight: variantRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + + CollapsibleSection { + id: colorSchemeSection + title: qsTr("Color scheme") + description: qsTr("Available color schemes") + showBackground: true + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small / 2 + + Repeater { + model: Schemes.list + + delegate: StyledRect { + required property var modelData + + Layout.fillWidth: true + + readonly property string schemeKey: `${modelData.name} ${modelData.flavour}` + readonly property bool isCurrent: schemeKey === Schemes.currentScheme + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: isCurrent ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + const name = modelData.name; + const flavour = modelData.flavour; + const schemeKey = `${name} ${flavour}`; + + // Optimistic update - set immediately for responsive UI + Schemes.currentScheme = schemeKey; + Quickshell.execDetached(["caelestia", "scheme", "set", "-n", name, "-f", flavour]); + + // Reload after a delay to confirm changes + Qt.callLater(() => { + reloadTimer.restart(); + }); + } + } + + Timer { + id: reloadTimer + interval: 300 + onTriggered: { + Schemes.reload(); + } + } + + RowLayout { + id: schemeRow + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledRect { + id: preview + + Layout.alignment: Qt.AlignVCenter + + border.width: 1 + border.color: Qt.alpha(`#${modelData.colours?.outline}`, 0.5) + + color: `#${modelData.colours?.surface}` + radius: Appearance.rounding.full + implicitWidth: iconPlaceholder.implicitWidth + implicitHeight: iconPlaceholder.implicitWidth + + MaterialIcon { + id: iconPlaceholder + visible: false + text: "circle" + font.pointSize: Appearance.font.size.large + } + + Item { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: parent.implicitWidth / 2 + clip: true + + StyledRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: preview.implicitWidth + color: `#${modelData.colours?.primary}` + radius: Appearance.rounding.full + } + } + } + + Column { + Layout.fillWidth: true + spacing: 0 + + StyledText { + text: modelData.flavour ?? "" + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: modelData.name ?? "" + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline + + elide: Text.ElideRight + anchors.left: parent.left + anchors.right: parent.right + } + } + + Loader { + active: isCurrent + asynchronous: true + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } + + implicitHeight: schemeRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + + CollapsibleSection { + id: animationsSection + title: qsTr("Animations") + showBackground: true + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Animation duration scale") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: animDurationsInput.implicitHeight + Appearance.padding.small * 2 + color: animDurationsInputHover.containsMouse || animDurationsInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: animDurationsInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: animDurationsInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: animDurationsInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: DoubleValidator { bottom: 0.1; top: 5.0 } + + Component.onCompleted: { + text = (rootPane.animDurationsScale).toFixed(1); + } + + onTextChanged: { + if (activeFocus) { + const val = parseFloat(text); + if (!isNaN(val) && val >= 0.1 && val <= 5.0) { + rootPane.animDurationsScale = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseFloat(text); + if (isNaN(val) || val < 0.1 || val > 5.0) { + text = (rootPane.animDurationsScale).toFixed(1); + } + } + } + } + + StyledText { + text: "×" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + } + + StyledSlider { + id: animDurationsSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0.1 + to: 5.0 + value: rootPane.animDurationsScale + onMoved: { + rootPane.animDurationsScale = animDurationsSlider.value; + if (!animDurationsInput.activeFocus) { + animDurationsInput.text = (animDurationsSlider.value).toFixed(1); + } + rootPane.saveConfig(); + } + } + } + } + } + + CollapsibleSection { + id: fontsSection + title: qsTr("Fonts") + showBackground: true + + CollapsibleSection { + id: materialFontSection + title: qsTr("Material font family") + expanded: true + showBackground: true + + Loader { + id: materialFontLoader + Layout.fillWidth: true + Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0 + asynchronous: true + active: materialFontSection.expanded + + sourceComponent: StyledListView { + id: materialFontList + property alias contentHeight: materialFontList.contentHeight + + clip: true + spacing: Appearance.spacing.small / 2 + model: Qt.fontFamilies() + + StyledScrollBar.vertical: StyledScrollBar { + flickable: materialFontList + } + + delegate: StyledRect { + required property string modelData + required property int index + + width: ListView.view.width + + readonly property bool isCurrent: modelData === rootPane.fontFamilyMaterial + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: isCurrent ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + rootPane.fontFamilyMaterial = modelData; + rootPane.saveConfig(); + } + } + + RowLayout { + id: fontFamilyMaterialRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledText { + text: modelData + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + Loader { + active: isCurrent + asynchronous: true + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } + + implicitHeight: fontFamilyMaterialRow.implicitHeight + Appearance.padding.normal * 2 + } + } + + } + } + + CollapsibleSection { + id: monoFontSection + title: qsTr("Monospace font family") + expanded: false + showBackground: true + + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0 + asynchronous: true + active: monoFontSection.expanded + + sourceComponent: StyledListView { + id: monoFontList + property alias contentHeight: monoFontList.contentHeight + + clip: true + spacing: Appearance.spacing.small / 2 + model: Qt.fontFamilies() + + StyledScrollBar.vertical: StyledScrollBar { + flickable: monoFontList + } + + delegate: StyledRect { + required property string modelData + required property int index + + width: ListView.view.width + + readonly property bool isCurrent: modelData === rootPane.fontFamilyMono + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: isCurrent ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + rootPane.fontFamilyMono = modelData; + rootPane.saveConfig(); + } + } + + RowLayout { + id: fontFamilyMonoRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledText { + text: modelData + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + Loader { + active: isCurrent + asynchronous: true + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } + + implicitHeight: fontFamilyMonoRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + + CollapsibleSection { + id: sansFontSection + title: qsTr("Sans-serif font family") + expanded: false + showBackground: true + + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0 + asynchronous: true + active: sansFontSection.expanded + + sourceComponent: StyledListView { + id: sansFontList + property alias contentHeight: sansFontList.contentHeight + + clip: true + spacing: Appearance.spacing.small / 2 + model: Qt.fontFamilies() + + StyledScrollBar.vertical: StyledScrollBar { + flickable: sansFontList + } + + delegate: StyledRect { + required property string modelData + required property int index + + width: ListView.view.width + + readonly property bool isCurrent: modelData === rootPane.fontFamilySans + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0) + radius: Appearance.rounding.normal + border.width: isCurrent ? 1 : 0 + border.color: Colours.palette.m3primary + + StateLayer { + function onClicked(): void { + rootPane.fontFamilySans = modelData; + rootPane.saveConfig(); + } + } + + RowLayout { + id: fontFamilySansRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + StyledText { + text: modelData + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + Loader { + active: isCurrent + asynchronous: true + + sourceComponent: MaterialIcon { + text: "check" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + } + } + + implicitHeight: fontFamilySansRow.implicitHeight + Appearance.padding.normal * 2 + } + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Font size scale") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: fontSizeInput.implicitHeight + Appearance.padding.small * 2 + color: fontSizeInputHover.containsMouse || fontSizeInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: fontSizeInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: fontSizeInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: fontSizeInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: DoubleValidator { bottom: 0.7; top: 1.5 } + + Component.onCompleted: { + text = (rootPane.fontSizeScale).toFixed(1); + } + + onTextChanged: { + if (activeFocus) { + const val = parseFloat(text); + if (!isNaN(val) && val >= 0.7 && val <= 1.5) { + rootPane.fontSizeScale = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseFloat(text); + if (isNaN(val) || val < 0.7 || val > 1.5) { + text = (rootPane.fontSizeScale).toFixed(1); + } + } + } + } + + StyledText { + text: "×" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + } + + StyledSlider { + id: fontSizeSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0.7 + to: 1.5 + value: rootPane.fontSizeScale + onMoved: { + rootPane.fontSizeScale = fontSizeSlider.value; + if (!fontSizeInput.activeFocus) { + fontSizeInput.text = (fontSizeSlider.value).toFixed(1); + } + rootPane.saveConfig(); + } + } + } + } + } + + CollapsibleSection { + id: scalesSection + title: qsTr("Scales") + showBackground: true + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Padding scale") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: paddingInput.implicitHeight + Appearance.padding.small * 2 + color: paddingInputHover.containsMouse || paddingInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: paddingInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: paddingInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: paddingInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: DoubleValidator { bottom: 0.5; top: 2.0 } + + Component.onCompleted: { + text = (rootPane.paddingScale).toFixed(1); + } + + onTextChanged: { + if (activeFocus) { + const val = parseFloat(text); + if (!isNaN(val) && val >= 0.5 && val <= 2.0) { + rootPane.paddingScale = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseFloat(text); + if (isNaN(val) || val < 0.5 || val > 2.0) { + text = (rootPane.paddingScale).toFixed(1); + } + } + } + } + + StyledText { + text: "×" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + } + + StyledSlider { + id: paddingSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0.5 + to: 2.0 + value: rootPane.paddingScale + onMoved: { + rootPane.paddingScale = paddingSlider.value; + if (!paddingInput.activeFocus) { + paddingInput.text = (paddingSlider.value).toFixed(1); + } + rootPane.saveConfig(); + } + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Rounding scale") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: roundingInput.implicitHeight + Appearance.padding.small * 2 + color: roundingInputHover.containsMouse || roundingInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: roundingInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: roundingInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: roundingInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: DoubleValidator { bottom: 0.1; top: 5.0 } + + Component.onCompleted: { + text = (rootPane.roundingScale).toFixed(1); + } + + onTextChanged: { + if (activeFocus) { + const val = parseFloat(text); + if (!isNaN(val) && val >= 0.1 && val <= 5.0) { + rootPane.roundingScale = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseFloat(text); + if (isNaN(val) || val < 0.1 || val > 5.0) { + text = (rootPane.roundingScale).toFixed(1); + } + } + } + } + + StyledText { + text: "×" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + } + + StyledSlider { + id: roundingSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0.1 + to: 5.0 + value: rootPane.roundingScale + onMoved: { + rootPane.roundingScale = roundingSlider.value; + if (!roundingInput.activeFocus) { + roundingInput.text = (roundingSlider.value).toFixed(1); + } + rootPane.saveConfig(); + } + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Spacing scale") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: spacingInput.implicitHeight + Appearance.padding.small * 2 + color: spacingInputHover.containsMouse || spacingInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: spacingInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: spacingInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: spacingInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: DoubleValidator { bottom: 0.1; top: 2.0 } + + Component.onCompleted: { + text = (rootPane.spacingScale).toFixed(1); + } + + onTextChanged: { + if (activeFocus) { + const val = parseFloat(text); + if (!isNaN(val) && val >= 0.1 && val <= 2.0) { + rootPane.spacingScale = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseFloat(text); + if (isNaN(val) || val < 0.1 || val > 2.0) { + text = (rootPane.spacingScale).toFixed(1); + } + } + } + } + + StyledText { + text: "×" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + } + + StyledSlider { + id: spacingSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0.1 + to: 2.0 + value: rootPane.spacingScale + onMoved: { + rootPane.spacingScale = spacingSlider.value; + if (!spacingInput.activeFocus) { + spacingInput.text = (spacingSlider.value).toFixed(1); + } + rootPane.saveConfig(); + } + } + } + } + } + + CollapsibleSection { + id: transparencySection + title: qsTr("Transparency") + showBackground: true + + SwitchRow { + label: qsTr("Transparency enabled") + checked: rootPane.transparencyEnabled + onToggled: checked => { + rootPane.transparencyEnabled = checked; + rootPane.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Transparency base") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: transparencyBaseInput.implicitHeight + Appearance.padding.small * 2 + color: transparencyBaseInputHover.containsMouse || transparencyBaseInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: transparencyBaseInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: transparencyBaseInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: transparencyBaseInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: IntValidator { bottom: 0; top: 100 } + + Component.onCompleted: { + text = Math.round(rootPane.transparencyBase * 100).toString(); + } + + onTextChanged: { + if (activeFocus) { + const val = parseInt(text); + if (!isNaN(val) && val >= 0 && val <= 100) { + rootPane.transparencyBase = val / 100; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseInt(text); + if (isNaN(val) || val < 0 || val > 100) { + text = Math.round(rootPane.transparencyBase * 100).toString(); + } + } + } + } + + StyledText { + text: "%" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + } + + StyledSlider { + id: baseSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0 + to: 100 + value: rootPane.transparencyBase * 100 + onMoved: { + rootPane.transparencyBase = baseSlider.value / 100; + if (!transparencyBaseInput.activeFocus) { + transparencyBaseInput.text = Math.round(baseSlider.value).toString(); + } + rootPane.saveConfig(); + } + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Transparency layers") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: transparencyLayersInput.implicitHeight + Appearance.padding.small * 2 + color: transparencyLayersInputHover.containsMouse || transparencyLayersInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: transparencyLayersInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: transparencyLayersInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: transparencyLayersInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: IntValidator { bottom: 0; top: 100 } + + Component.onCompleted: { + text = Math.round(rootPane.transparencyLayers * 100).toString(); + } + + onTextChanged: { + if (activeFocus) { + const val = parseInt(text); + if (!isNaN(val) && val >= 0 && val <= 100) { + rootPane.transparencyLayers = val / 100; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseInt(text); + if (isNaN(val) || val < 0 || val > 100) { + text = Math.round(rootPane.transparencyLayers * 100).toString(); + } + } + } + } + + StyledText { + text: "%" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.normal + } + } + + StyledSlider { + id: layersSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0 + to: 100 + value: rootPane.transparencyLayers * 100 + onMoved: { + rootPane.transparencyLayers = layersSlider.value / 100; + if (!transparencyLayersInput.activeFocus) { + transparencyLayersInput.text = Math.round(layersSlider.value).toString(); + } + rootPane.saveConfig(); + } + } + } + } + } + + CollapsibleSection { + id: borderSection + title: qsTr("Border") + showBackground: true + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Border rounding") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: borderRoundingInput.implicitHeight + Appearance.padding.small * 2 + color: borderRoundingInputHover.containsMouse || borderRoundingInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: borderRoundingInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: borderRoundingInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: borderRoundingInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: DoubleValidator { bottom: 0.1; top: 100 } + + Component.onCompleted: { + text = (rootPane.borderRounding).toFixed(1); + } + + onTextChanged: { + if (activeFocus) { + const val = parseFloat(text); + if (!isNaN(val) && val >= 0.1 && val <= 100) { + rootPane.borderRounding = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseFloat(text); + if (isNaN(val) || val < 0.1 || val > 100) { + text = (rootPane.borderRounding).toFixed(1); + } + } + } + } + } + + StyledSlider { + id: borderRoundingSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0.1 + to: 100 + value: rootPane.borderRounding + onMoved: { + rootPane.borderRounding = borderRoundingSlider.value; + if (!borderRoundingInput.activeFocus) { + borderRoundingInput.text = (borderRoundingSlider.value).toFixed(1); + } + rootPane.saveConfig(); + } + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Border thickness") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: borderThicknessInput.implicitHeight + Appearance.padding.small * 2 + color: borderThicknessInputHover.containsMouse || borderThicknessInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: borderThicknessInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: borderThicknessInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: borderThicknessInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: DoubleValidator { bottom: 0.1; top: 100 } + + Component.onCompleted: { + text = (rootPane.borderThickness).toFixed(1); + } + + onTextChanged: { + if (activeFocus) { + const val = parseFloat(text); + if (!isNaN(val) && val >= 0.1 && val <= 100) { + rootPane.borderThickness = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseFloat(text); + if (isNaN(val) || val < 0.1 || val > 100) { + text = (rootPane.borderThickness).toFixed(1); + } + } + } + } + } + + StyledSlider { + id: borderThicknessSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0.1 + to: 100 + value: rootPane.borderThickness + onMoved: { + rootPane.borderThickness = borderThicknessSlider.value; + if (!borderThicknessInput.activeFocus) { + borderThicknessInput.text = (borderThicknessSlider.value).toFixed(1); + } + rootPane.saveConfig(); + } + } + } + } + } + + CollapsibleSection { + id: backgroundSection + title: qsTr("Background") + showBackground: true + + SwitchRow { + label: qsTr("Desktop clock") + checked: rootPane.desktopClockEnabled + onToggled: checked => { + rootPane.desktopClockEnabled = checked; + rootPane.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Background enabled") + checked: rootPane.backgroundEnabled + onToggled: checked => { + rootPane.backgroundEnabled = checked; + rootPane.saveConfig(); + } + } + + StyledText { + Layout.topMargin: Appearance.spacing.normal + text: qsTr("Visualiser") + font.pointSize: Appearance.font.size.larger + font.weight: 500 + } + + SwitchRow { + label: qsTr("Visualiser enabled") + checked: rootPane.visualiserEnabled + onToggled: checked => { + rootPane.visualiserEnabled = checked; + rootPane.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Visualiser auto hide") + checked: rootPane.visualiserAutoHide + onToggled: checked => { + rootPane.visualiserAutoHide = checked; + rootPane.saveConfig(); + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Visualiser rounding") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: visualiserRoundingInput.implicitHeight + Appearance.padding.small * 2 + color: visualiserRoundingInputHover.containsMouse || visualiserRoundingInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: visualiserRoundingInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: visualiserRoundingInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: visualiserRoundingInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: IntValidator { bottom: 0; top: 10 } + + Component.onCompleted: { + text = Math.round(rootPane.visualiserRounding).toString(); + } + + onTextChanged: { + if (activeFocus) { + const val = parseInt(text); + if (!isNaN(val) && val >= 0 && val <= 10) { + rootPane.visualiserRounding = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseInt(text); + if (isNaN(val) || val < 0 || val > 10) { + text = Math.round(rootPane.visualiserRounding).toString(); + } + } + } + } + } + + StyledSlider { + id: visualiserRoundingSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0 + to: 10 + stepSize: 1 + value: rootPane.visualiserRounding + onMoved: { + rootPane.visualiserRounding = Math.round(visualiserRoundingSlider.value); + if (!visualiserRoundingInput.activeFocus) { + visualiserRoundingInput.text = Math.round(visualiserRoundingSlider.value).toString(); + } + rootPane.saveConfig(); + } + } + } + } + + SectionContainer { + contentSpacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + StyledText { + text: qsTr("Visualiser spacing") + font.pointSize: Appearance.font.size.normal + } + + Item { + Layout.fillWidth: true + } + + StyledRect { + Layout.preferredWidth: 70 + implicitHeight: visualiserSpacingInput.implicitHeight + Appearance.padding.small * 2 + color: visualiserSpacingInputHover.containsMouse || visualiserSpacingInput.activeFocus + ? Colours.layer(Colours.palette.m3surfaceContainer, 3) + : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: visualiserSpacingInput.activeFocus + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { CAnim {} } + Behavior on border.color { CAnim {} } + + MouseArea { + id: visualiserSpacingInputHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.IBeamCursor + acceptedButtons: Qt.NoButton + } + + StyledTextField { + id: visualiserSpacingInput + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignHCenter + validator: DoubleValidator { bottom: 0; top: 2 } + + Component.onCompleted: { + text = (rootPane.visualiserSpacing).toFixed(1); + } + + onTextChanged: { + if (activeFocus) { + const val = parseFloat(text); + if (!isNaN(val) && val >= 0 && val <= 2) { + rootPane.visualiserSpacing = val; + rootPane.saveConfig(); + } + } + } + onEditingFinished: { + const val = parseFloat(text); + if (isNaN(val) || val < 0 || val > 2) { + text = (rootPane.visualiserSpacing).toFixed(1); + } + } + } + } + } + + StyledSlider { + id: visualiserSpacingSlider + + Layout.fillWidth: true + implicitHeight: Appearance.padding.normal * 3 + + from: 0 + to: 2 + value: rootPane.visualiserSpacing + onMoved: { + rootPane.visualiserSpacing = visualiserSpacingSlider.value; + if (!visualiserSpacingInput.activeFocus) { + visualiserSpacingInput.text = (visualiserSpacingSlider.value).toFixed(1); + } + rootPane.saveConfig(); + } + } + } + } + } + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ClippingRectangle { + id: rightAppearanceClippingRect + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: 0 + anchors.rightMargin: Appearance.padding.normal / 2 + radius: rightAppearanceBorder.innerRadius + color: "transparent" + + Loader { + id: rightAppearanceLoader + anchors.fill: parent + anchors.margins: Appearance.padding.large * 2 + asynchronous: true + sourceComponent: appearanceRightContentComponent + property var rootPane: root + + onStatusChanged: { + if (status === Loader.Error) { + console.error("[AppearancePane] Right appearance loader error!"); + } + } + } + } + + InnerBorder { + id: rightAppearanceBorder + leftThickness: Appearance.padding.normal / 2 + } + + Component { + id: appearanceRightContentComponent + + StyledFlickable { + id: rightAppearanceFlickable + flickableDirection: Flickable.VerticalFlick + contentHeight: contentLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: rightAppearanceFlickable + } + + ColumnLayout { + id: contentLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Appearance.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter | Qt.AlignTop + Layout.topMargin: 0 + text: "palette" + font.pointSize: Appearance.font.size.extraLarge * 3 + font.bold: true + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Appearance Settings") + font.pointSize: Appearance.font.size.large + font.bold: true + } + + StyledText { + Layout.topMargin: Appearance.spacing.large + Layout.alignment: Qt.AlignHCenter + text: qsTr("Wallpaper") + font.pointSize: Appearance.font.size.extraLarge + font.weight: 600 + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Select a wallpaper") + font.pointSize: Appearance.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } + + Item { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.large + Layout.preferredHeight: wallpaperLoader.item ? wallpaperLoader.item.layoutPreferredHeight : 0 + + Loader { + id: wallpaperLoader + anchors.fill: parent + asynchronous: true + active: { + // Lazy load: only activate when: + // 1. Right pane is loaded AND + // 2. Appearance pane is active (index 3) or adjacent (for smooth transitions) + // This prevents loading all wallpapers when control center opens but appearance pane isn't visible + const isActive = root.session.activeIndex === 3; + const isAdjacent = Math.abs(root.session.activeIndex - 3) === 1; + const shouldActivate = rightAppearanceLoader.item !== null && (isActive || isAdjacent); + return shouldActivate; + } + + onStatusChanged: { + if (status === Loader.Error) { + console.error("[AppearancePane] Wallpaper loader error!"); + } + } + + // Stop lazy loading when loader becomes inactive + onActiveChanged: { + if (!active && wallpaperLoader.item) { + const container = wallpaperLoader.item; + // Access timer through wallpaperGrid + if (container && container.wallpaperGrid) { + if (container.wallpaperGrid.scrollCheckTimer) { + container.wallpaperGrid.scrollCheckTimer.stop(); + } + container.wallpaperGrid._expansionInProgress = false; + } + } + } + + sourceComponent: Item { + id: wallpaperGridContainer + property alias layoutPreferredHeight: wallpaperGrid.layoutPreferredHeight + + // Find and store reference to parent Flickable for scroll monitoring + property var parentFlickable: { + let item = parent; + while (item) { + if (item.flickableDirection !== undefined) { + return item; + } + item = item.parent; + } + return null; + } + + // Cleanup when component is destroyed + Component.onDestruction: { + if (wallpaperGrid) { + if (wallpaperGrid.scrollCheckTimer) { + wallpaperGrid.scrollCheckTimer.stop(); + } + wallpaperGrid._expansionInProgress = false; + } + } + + // Lazy loading model: loads one image at a time, only when touching bottom + // This prevents GridView from creating all delegates at once + QtObject { + id: lazyModel + + property var sourceList: null + property int loadedCount: 0 // Total items available to load + property int visibleCount: 0 // Items actually exposed to GridView (only visible + buffer) + property int totalCount: 0 + + function initialize(list) { + sourceList = list; + totalCount = list ? list.length : 0; + // Start with enough items to fill the initial viewport (~3 rows) + const initialRows = 3; + const cols = wallpaperGrid.columnsCount > 0 ? wallpaperGrid.columnsCount : 3; + const initialCount = Math.min(initialRows * cols, totalCount); + loadedCount = initialCount; + visibleCount = initialCount; + } + + function loadOneRow() { + if (loadedCount < totalCount) { + const cols = wallpaperGrid.columnsCount > 0 ? wallpaperGrid.columnsCount : 1; + const itemsToLoad = Math.min(cols, totalCount - loadedCount); + loadedCount += itemsToLoad; + } + } + + function updateVisibleCount(neededCount) { + // Always round up to complete rows to avoid incomplete rows in the grid + const cols = wallpaperGrid.columnsCount > 0 ? wallpaperGrid.columnsCount : 1; + const maxVisible = Math.min(neededCount, loadedCount); + const rows = Math.ceil(maxVisible / cols); + const newVisibleCount = Math.min(rows * cols, loadedCount); + + if (newVisibleCount > visibleCount) { + visibleCount = newVisibleCount; + } + } + } + + GridView { + id: wallpaperGrid + anchors.fill: parent + + property int _delegateCount: 0 + + readonly property int minCellWidth: 200 + Appearance.spacing.normal + readonly property int columnsCount: Math.max(1, Math.floor(parent.width / minCellWidth)) + + // Height based on visible items only - prevents GridView from creating all delegates + readonly property int layoutPreferredHeight: { + if (!lazyModel || lazyModel.visibleCount === 0 || columnsCount === 0) { + return 0; + } + const calculated = Math.ceil(lazyModel.visibleCount / columnsCount) * cellHeight; + return calculated; + } + + height: layoutPreferredHeight + cellWidth: width / columnsCount + cellHeight: 140 + Appearance.spacing.normal + + leftMargin: 0 + rightMargin: 0 + topMargin: 0 + bottomMargin: 0 + + // Use ListModel for incremental updates to prevent flashing when new items are added + ListModel { + id: wallpaperListModel + } + + model: wallpaperListModel + + Connections { + target: lazyModel + function onVisibleCountChanged(): void { + if (!lazyModel || !lazyModel.sourceList) return; + + const newCount = lazyModel.visibleCount; + const currentCount = wallpaperListModel.count; + + // Only append new items - never remove or replace existing ones + if (newCount > currentCount) { + const flickable = wallpaperGridContainer.parentFlickable; + const oldScrollY = flickable ? flickable.contentY : 0; + + for (let i = currentCount; i < newCount; i++) { + wallpaperListModel.append({modelData: lazyModel.sourceList[i]}); + } + + // Preserve scroll position after model update + if (flickable) { + Qt.callLater(function() { + if (Math.abs(flickable.contentY - oldScrollY) < 1) { + flickable.contentY = oldScrollY; + } + }); + } + } + } + } + + Component.onCompleted: { + Qt.callLater(function() { + const isActive = root.session.activeIndex === 3; + if (width > 0 && parent && parent.visible && isActive && Wallpapers.list) { + lazyModel.initialize(Wallpapers.list); + wallpaperListModel.clear(); + for (let i = 0; i < lazyModel.visibleCount; i++) { + wallpaperListModel.append({modelData: lazyModel.sourceList[i]}); + } + } + }); + } + + Connections { + target: root.session + function onActiveIndexChanged(): void { + const isActive = root.session.activeIndex === 3; + + // Stop lazy loading when switching away from appearance pane + if (!isActive) { + if (scrollCheckTimer) { + scrollCheckTimer.stop(); + } + if (wallpaperGrid) { + wallpaperGrid._expansionInProgress = false; + } + return; + } + + // Initialize if needed when switching to appearance pane + if (isActive && width > 0 && !lazyModel.sourceList && parent && parent.visible && Wallpapers.list) { + lazyModel.initialize(Wallpapers.list); + wallpaperListModel.clear(); + for (let i = 0; i < lazyModel.visibleCount; i++) { + wallpaperListModel.append({modelData: lazyModel.sourceList[i]}); + } + } + } + } + + onWidthChanged: { + const isActive = root.session.activeIndex === 3; + if (width > 0 && !lazyModel.sourceList && parent && parent.visible && isActive && Wallpapers.list) { + lazyModel.initialize(Wallpapers.list); + wallpaperListModel.clear(); + for (let i = 0; i < lazyModel.visibleCount; i++) { + wallpaperListModel.append({modelData: lazyModel.sourceList[i]}); + } + } + } + + // Force true lazy loading: only create delegates for visible items + displayMarginBeginning: 0 + displayMarginEnd: 0 + cacheBuffer: 0 + + // Debounce expansion to avoid too frequent checks + property bool _expansionInProgress: false + + Connections { + target: wallpaperGridContainer.parentFlickable + function onContentYChanged(): void { + // Don't process scroll events if appearance pane is not active + const isActive = root.session.activeIndex === 3; + if (!isActive) return; + + if (!lazyModel || !lazyModel.sourceList || lazyModel.loadedCount >= lazyModel.totalCount || wallpaperGrid._expansionInProgress) { + return; + } + + const flickable = wallpaperGridContainer.parentFlickable; + if (!flickable) return; + + const gridY = wallpaperGridContainer.y; + const scrollY = flickable.contentY; + const viewportHeight = flickable.height; + + const topY = scrollY - gridY; + const bottomY = scrollY + viewportHeight - gridY; + + if (bottomY < 0) return; + + const topRow = Math.max(0, Math.floor(topY / wallpaperGrid.cellHeight)); + const bottomRow = Math.floor(bottomY / wallpaperGrid.cellHeight); + + // Update visible count with 1 row buffer ahead + const bufferRows = 1; + const neededBottomRow = bottomRow + bufferRows; + const neededCount = Math.min((neededBottomRow + 1) * wallpaperGrid.columnsCount, lazyModel.loadedCount); + lazyModel.updateVisibleCount(neededCount); + + // Load more when we're within 1 row of running out of loaded items + const loadedRows = Math.ceil(lazyModel.loadedCount / wallpaperGrid.columnsCount); + const rowsRemaining = loadedRows - (bottomRow + 1); + + if (rowsRemaining <= 1 && lazyModel.loadedCount < lazyModel.totalCount) { + if (!wallpaperGrid._expansionInProgress) { + wallpaperGrid._expansionInProgress = true; + lazyModel.loadOneRow(); + Qt.callLater(function() { + wallpaperGrid._expansionInProgress = false; + }); + } + } + } + } + + // Fallback timer to check scroll position periodically + Timer { + id: scrollCheckTimer + interval: 100 + running: { + const isActive = root.session.activeIndex === 3; + return isActive && lazyModel && lazyModel.sourceList && lazyModel.loadedCount < lazyModel.totalCount; + } + repeat: true + onTriggered: { + // Double-check that appearance pane is still active + const isActive = root.session.activeIndex === 3; + if (!isActive) { + stop(); + return; + } + + const flickable = wallpaperGridContainer.parentFlickable; + if (!flickable || !lazyModel || !lazyModel.sourceList) return; + + const gridY = wallpaperGridContainer.y; + const scrollY = flickable.contentY; + const viewportHeight = flickable.height; + + const topY = scrollY - gridY; + const bottomY = scrollY + viewportHeight - gridY; + if (bottomY < 0) return; + + const topRow = Math.max(0, Math.floor(topY / wallpaperGrid.cellHeight)); + const bottomRow = Math.floor(bottomY / wallpaperGrid.cellHeight); + + const bufferRows = 1; + const neededBottomRow = bottomRow + bufferRows; + const neededCount = Math.min((neededBottomRow + 1) * wallpaperGrid.columnsCount, lazyModel.loadedCount); + lazyModel.updateVisibleCount(neededCount); + + // Load more when we're within 1 row of running out of loaded items + const loadedRows = Math.ceil(lazyModel.loadedCount / wallpaperGrid.columnsCount); + const rowsRemaining = loadedRows - (bottomRow + 1); + + if (rowsRemaining <= 1 && lazyModel.loadedCount < lazyModel.totalCount) { + if (!wallpaperGrid._expansionInProgress) { + wallpaperGrid._expansionInProgress = true; + lazyModel.loadOneRow(); + Qt.callLater(function() { + wallpaperGrid._expansionInProgress = false; + }); + } + } + } + } + + + // Parent Flickable handles scrolling + interactive: false + + + delegate: Item { + required property var modelData + + width: wallpaperGrid.cellWidth + height: wallpaperGrid.cellHeight + + readonly property bool isCurrent: modelData.path === Wallpapers.actualCurrent + readonly property real itemMargin: Appearance.spacing.normal / 2 + readonly property real itemRadius: Appearance.rounding.normal + + Component.onCompleted: { + wallpaperGrid._delegateCount++; + } + + StateLayer { + anchors.fill: parent + anchors.leftMargin: itemMargin + anchors.rightMargin: itemMargin + anchors.topMargin: itemMargin + anchors.bottomMargin: itemMargin + radius: itemRadius + + function onClicked(): void { + Wallpapers.setWallpaper(modelData.path); + } + } + + StyledClippingRect { + id: image + + anchors.fill: parent + anchors.leftMargin: itemMargin + anchors.rightMargin: itemMargin + anchors.topMargin: itemMargin + anchors.bottomMargin: itemMargin + color: Colours.tPalette.m3surfaceContainer + radius: itemRadius + antialiasing: true + layer.enabled: true + layer.smooth: true + + CachingImage { + id: cachingImage + + path: modelData.path + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + cache: true + visible: opacity > 0 + antialiasing: true + smooth: true + + opacity: status === Image.Ready ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutQuad + } + } + } + + // Fallback if CachingImage fails to load + Image { + id: fallbackImage + + anchors.fill: parent + source: fallbackTimer.triggered && cachingImage.status !== Image.Ready ? modelData.path : "" + asynchronous: true + fillMode: Image.PreserveAspectCrop + cache: true + visible: opacity > 0 + antialiasing: true + smooth: true + + opacity: status === Image.Ready && cachingImage.status !== Image.Ready ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutQuad + } + } + } + + Timer { + id: fallbackTimer + + property bool triggered: false + interval: 800 + running: cachingImage.status === Image.Loading || cachingImage.status === Image.Null + onTriggered: triggered = true + } + + // Gradient overlay for filename + Rectangle { + id: filenameOverlay + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + implicitHeight: filenameText.implicitHeight + Appearance.padding.normal * 1.5 + radius: 0 + + gradient: Gradient { + GradientStop { + position: 0.0 + color: Qt.rgba(Colours.palette.m3surfaceContainer.r, + Colours.palette.m3surfaceContainer.g, + Colours.palette.m3surfaceContainer.b, 0) + } + GradientStop { + position: 0.3 + color: Qt.rgba(Colours.palette.m3surfaceContainer.r, + Colours.palette.m3surfaceContainer.g, + Colours.palette.m3surfaceContainer.b, 0.7) + } + GradientStop { + position: 0.6 + color: Qt.rgba(Colours.palette.m3surfaceContainer.r, + Colours.palette.m3surfaceContainer.g, + Colours.palette.m3surfaceContainer.b, 0.9) + } + GradientStop { + position: 1.0 + color: Qt.rgba(Colours.palette.m3surfaceContainer.r, + Colours.palette.m3surfaceContainer.g, + Colours.palette.m3surfaceContainer.b, 0.95) + } + } + + opacity: 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutCubic + } + } + + Component.onCompleted: { + opacity = 1; + } + } + } + + Rectangle { + anchors.fill: parent + anchors.leftMargin: itemMargin + anchors.rightMargin: itemMargin + anchors.topMargin: itemMargin + anchors.bottomMargin: itemMargin + color: "transparent" + radius: itemRadius + border.width + border.width: isCurrent ? 2 : 0 + border.color: Colours.palette.m3primary + antialiasing: true + smooth: true + + Behavior on border.width { + NumberAnimation { + duration: 150 + easing.type: Easing.OutQuad + } + } + + MaterialIcon { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.small + + visible: isCurrent + text: "check_circle" + color: Colours.palette.m3primary + font.pointSize: Appearance.font.size.large + } + } + + StyledText { + id: filenameText + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Appearance.padding.normal + Appearance.spacing.normal / 2 + anchors.rightMargin: Appearance.padding.normal + Appearance.spacing.normal / 2 + anchors.bottomMargin: Appearance.padding.normal + + readonly property string fileName: { + const path = modelData.relativePath || ""; + const parts = path.split("/"); + return parts.length > 0 ? parts[parts.length - 1] : path; + } + + text: fileName + font.pointSize: Appearance.font.size.smaller + font.weight: 500 + color: isCurrent ? Colours.palette.m3primary : Colours.palette.m3onSurface + elide: Text.ElideMiddle + maximumLineCount: 1 + horizontalAlignment: Text.AlignHCenter + + opacity: 0 + + Behavior on opacity { + NumberAnimation { + duration: 1000 + easing.type: Easing.OutCubic + } + } + + Component.onCompleted: { + opacity = 1; + } + } + } + } + } + } + } + } + } + } + } +} |