nosignal-shell/modules/controlcenter/appearance/AppearancePane.qml

2377 lines
118 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
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("Settings")
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")
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")
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")
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")
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")
CollapsibleSection {
id: materialFontSection
title: qsTr("Material font family")
expanded: true
Loader {
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()
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
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()
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
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()
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")
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")
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")
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")
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!");
}
}
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;
}
// 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;
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 {
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: lazyModel && lazyModel.sourceList && lazyModel.loadedCount < lazyModel.totalCount
repeat: true
onTriggered: {
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;
}
}
}
}
}
}
}
}
}
}
}
}