diff --git a/README.md b/README.md index 13b5ec8a..1dfbd205 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ For example, to disable the bar on DP-1: > - `utilities` (`toasts`, `vpn`) > - `services` (`weatherLocation`, `useFahrenheit`, `useFahrenheitPerformance`, `useTwelveHourClock`, > `gpuType`, `visualiserBars`, `audioIncrement`, `brightnessIncrement`, `maxVolume`, `smartScheme`, -> `defaultPlayer`, `playerAliases`, `showLyrics`, `lyricsBackend`) +> `defaultPlayer`, `playerAliases`, `lyricsBackend`) > - `paths` (`wallpaperDir`, `lyricsDir`) > > diff --git a/assets/wallpaper.webp b/assets/wallpaper.webp new file mode 100644 index 00000000..ef273bbe Binary files /dev/null and b/assets/wallpaper.webp differ diff --git a/components/AnimLoader.qml b/components/AnimLoader.qml new file mode 100644 index 00000000..a3ef4ddc --- /dev/null +++ b/components/AnimLoader.qml @@ -0,0 +1,42 @@ +import QtQuick + +Loader { + id: root + + property Component sourceComp + property bool isComplete + property int outAnimType: Anim.FastEffects + property int inAnimType: Anim.DefaultEffects + + asynchronous: true + Component.onCompleted: { + isComplete = true; + sourceComponent = sourceComp; + } + onSourceCompChanged: { + if (isComplete) + anim.restart(); + } + + SequentialAnimation { + id: anim + + running: false + + Anim { + target: root + property: "opacity" + to: 0 + type: root.outAnimType + } + ScriptAction { + script: root.sourceComponent = root.sourceComp + } + Anim { + target: root + property: "opacity" + to: 1 + type: root.inAnimType + } + } +} diff --git a/components/StateLayer.qml b/components/StateLayer.qml index ca177f49..d30ab731 100644 --- a/components/StateLayer.qml +++ b/components/StateLayer.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Shapes +import Caelestia import Caelestia.Config import qs.services @@ -8,10 +9,11 @@ MouseArea { property bool disabled property bool showHoverBackground: true + property bool manualPressOverride readonly property alias rect: base property bool shapeMorph - property real stateOpacity: pressed ? 0.1 : containsMouse ? 0.08 : 0 + property real stateOpacity: containsMouse ? 0.08 : 0 property real pressX: width / 2 property real pressY: height / 2 @@ -29,7 +31,7 @@ MouseArea { const d2 = distSq(width, 0); const d3 = distSq(0, height); const d4 = distSq(width, height); - return Math.sqrt(Math.max(d1, d2, d3, d4)) + (shapeMorph ? 24 : 0); + return (Math.sqrt(Math.max(d1, d2, d3, d4)) + (shapeMorph ? 24 : 0)) * 1.3; } property real endRadiusAtPress @@ -59,12 +61,17 @@ MouseArea { onPressed: e => press(e.x, e.y) onPressedChanged: { - if (!pressed && !rippleAnim.running && circle.opacity > 0) + if (!(pressed || manualPressOverride) && !rippleAnim.running && circle.opacity > 0) + fadeAnim.start(); + } + + onManualPressOverrideChanged: { + if (!(pressed || manualPressOverride) && circleRadius > endRadiusAtPress * 0.99 && !fadeAnim.running) fadeAnim.start(); } onCircleRadiusChanged: { - if (!pressed && circleRadius > endRadiusAtPress * 0.99 && !fadeAnim.running) + if (!(pressed || manualPressOverride) && circleRadius > endRadiusAtPress * 0.99 && !fadeAnim.running) fadeAnim.start(); } @@ -75,7 +82,7 @@ MouseArea { target: root property: "circleRadius" to: root.endRadius - easing: Tokens.anim.expressiveSlowEffects + easing: Tokens.anim.standard duration: Tokens.anim.durations.expressiveSlowEffects * 2 } @@ -94,8 +101,14 @@ MouseArea { anchors.fill: parent opacity: root.stateOpacity color: Colours.palette.m3onSurface - // Pick up radius from parent if it has one (parent can be anything with a radius property) - radius: root.parent?.radius ?? 0 // qmllint disable missing-property + // Pick up radius from parent if it has one (parent can be anything with radius props) + // qmllint disable missing-property + radius: root.parent?.radius ?? 0 + topLeftRadius: root.parent?.topLeftRadius ?? radius ?? 0 + topRightRadius: root.parent?.topRightRadius ?? radius ?? 0 + bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 + bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 + // qmllint enable missing-property } Shape { @@ -121,12 +134,12 @@ MouseArea { color: Qt.alpha(base.color, 1) } GradientStop { - position: 0.99 + position: CUtils.clamp(1 - 0.2 * root.endRadius / root.circleRadius, 0.01, 0.99) color: Qt.alpha(base.color, 1) } GradientStop { position: 1 - color: Qt.alpha(base.color, 0) + color: Qt.alpha(base.color, CUtils.clamp((root.circleRadius / root.endRadius - 0.9) / 0.1, 0, 1)) } } diff --git a/components/containers/StyledFlickable.qml b/components/containers/StyledFlickable.qml index b792e583..5411c4e1 100644 --- a/components/containers/StyledFlickable.qml +++ b/components/containers/StyledFlickable.qml @@ -4,11 +4,28 @@ import qs.components Flickable { id: root + property bool doneFakeFlick + maximumFlickVelocity: 3000 rebound: Transition { + onRunningChanged: { + if (!running && !root.doneFakeFlick) { + root.doneFakeFlick = true; + root.flick(1, 1); + root.flick(-1, -1); + Qt.callLater(() => root.cancelFlick()); + } + } + Anim { properties: "x,y" } } + + Timer { + running: root.doneFakeFlick + interval: 10 + onTriggered: root.doneFakeFlick = false + } } diff --git a/components/containers/StyledListView.qml b/components/containers/StyledListView.qml index 8b5a4401..484421e5 100644 --- a/components/containers/StyledListView.qml +++ b/components/containers/StyledListView.qml @@ -4,11 +4,28 @@ import qs.components ListView { id: root + property bool doneFakeFlick + maximumFlickVelocity: 3000 rebound: Transition { + onRunningChanged: { + if (!running && !root.doneFakeFlick) { + root.doneFakeFlick = true; + root.flick(1, 1); + root.flick(-1, -1); + Qt.callLater(() => root.cancelFlick()); + } + } + Anim { properties: "x,y" } } + + Timer { + running: root.doneFakeFlick + interval: 10 + onTriggered: root.doneFakeFlick = false + } } diff --git a/components/containers/VerticalFadeFlickable.qml b/components/containers/VerticalFadeFlickable.qml new file mode 100644 index 00000000..020972ff --- /dev/null +++ b/components/containers/VerticalFadeFlickable.qml @@ -0,0 +1,72 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.components +import qs.components.effects + +StyledFlickable { + id: root + + property real fadeAmount: 0.2 + + property real topFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 + property real bottomFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 + + function fadeShouldBeActive(isStart: bool): bool { + // When content is smaller than flickable size, hide fade when rebound starts + if (contentHeight + topMargin + bottomMargin < height && rebound.running && ((isStart ? verticalOvershoot > 0 : verticalOvershoot < 0))) + return false; + + if (isStart) + return visibleArea.yPosition > 0; + return visibleArea.yPosition + visibleArea.heightRatio < 1; + } + + flickableDirection: Flickable.VerticalFlick + + layer.enabled: true + layer.effect: Mask { + maskSource: mask + + Rectangle { + id: mask + + anchors.fill: parent + visible: false + layer.enabled: true + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + position: 0 + color: Qt.rgba(0, 0, 0, root.topFadeOpacity) + } + GradientStop { + position: root.fadeAmount + color: Qt.rgba(0, 0, 0, 1) + } + GradientStop { + position: 1 - root.fadeAmount + color: Qt.rgba(0, 0, 0, 1) + } + GradientStop { + position: 1 + color: Qt.rgba(0, 0, 0, root.bottomFadeOpacity) + } + } + } + } + + Behavior on topFadeOpacity { + Anim { + type: Anim.SlowEffects + } + } + + Behavior on bottomFadeOpacity { + Anim { + type: Anim.SlowEffects + } + } +} diff --git a/components/controls/ButtonBase.qml b/components/controls/ButtonBase.qml index 4c85bc9e..ab263148 100644 --- a/components/controls/ButtonBase.qml +++ b/components/controls/ButtonBase.qml @@ -1,5 +1,3 @@ -//@ pragma Internal - import QtQuick import Caelestia.Config import qs.components @@ -35,10 +33,10 @@ StyledRect { readonly property alias stateLayer: stateLayer readonly property alias radiusAnim: radiusAnim - required property color activeColour - required property color inactiveColour - required property color activeOnColour - required property color inactiveOnColour + property color activeColour + property color inactiveColour + property color activeOnColour + property color inactiveOnColour property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1) property color disabledOnColour: Qt.alpha(Colours.palette.m3onSurface, 0.38) @@ -60,7 +58,7 @@ StyledRect { if (internalChecked) return checkedRadius; if (isRound) - return implicitHeight / 2 * Math.min(1, Tokens.rounding.scale); + return (height || implicitHeight) / 2 * Math.min(1, Tokens.rounding.scale); return defaultRadius; } color: type === ButtonBase.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour diff --git a/components/controls/IconTextButton.qml b/components/controls/IconTextButton.qml index 8d1bf264..6859cb68 100644 --- a/components/controls/IconTextButton.qml +++ b/components/controls/IconTextButton.qml @@ -13,10 +13,27 @@ ButtonBase { readonly property alias iconLabel: iconLabel readonly property alias label: label - activeColour: type === IconTextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary - inactiveColour: type === IconTextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer - activeOnColour: type === IconTextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary - inactiveOnColour: type === IconTextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + horizontalPadding: Tokens.padding.medium + verticalPadding: Tokens.padding.small + + activeColour: type === TextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary + inactiveColour: { + if (!isToggle && type === TextButton.Filled) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer; + } + activeOnColour: { + if (type === TextButton.Text) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary; + } + inactiveOnColour: { + if (!isToggle && type === TextButton.Filled) + return Colours.palette.m3onPrimary; + if (type === TextButton.Text) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer; + } implicitWidth: row.implicitWidth + horizontalPadding * 2 implicitHeight: row.implicitHeight + verticalPadding * 2 @@ -34,6 +51,11 @@ ButtonBase { Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575) color: root.onColour fill: root.internalChecked ? 1 : 0 + fontStyle: { + const f = Qt.font(root.font); + f.pointSize = Math.round(root.font.pointSize * 1.1); + return f; + } Behavior on fill { Anim { @@ -48,6 +70,7 @@ ButtonBase { Layout.alignment: Qt.AlignVCenter Layout.topMargin: -Math.round(iconLabel.fontInfo.pointSize * 0.0575) color: root.onColour + font: root.font } } } diff --git a/components/controls/Menu.qml b/components/controls/Menu.qml index ffa5d14e..db36d128 100644 --- a/components/controls/Menu.qml +++ b/components/controls/Menu.qml @@ -116,7 +116,7 @@ MouseArea { required property int index required property MenuItem modelData - readonly property bool active: modelData === root.active + readonly property bool active: modelData === root?.active Layout.fillWidth: true implicitWidth: menuOptionRow.implicitWidth + Tokens.padding.medium * 2 @@ -125,8 +125,8 @@ MouseArea { radius: active ? Tokens.rounding.medium : Tokens.rounding.extraSmall topLeftRadius: index === 0 ? Tokens.rounding.medium : radius topRightRadius: index === 0 ? Tokens.rounding.medium : radius - bottomLeftRadius: index === repeater.count - 1 ? Tokens.rounding.medium : radius - bottomRightRadius: index === repeater.count - 1 ? Tokens.rounding.medium : radius + bottomLeftRadius: index === repeater?.count - 1 ? Tokens.rounding.medium : radius + bottomRightRadius: index === repeater?.count - 1 ? Tokens.rounding.medium : radius color: Qt.alpha(Colours.palette.m3tertiaryContainer, active ? 1 : 0) @@ -159,21 +159,21 @@ MouseArea { MaterialIcon { Layout.alignment: Qt.AlignVCenter - text: item.modelData.icon + text: item.modelData?.icon ?? "" color: item.active ? Colours.palette.m3onTertiaryContainer : Colours.palette.m3onSurfaceVariant } StyledText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true - text: item.modelData.text + text: item.modelData?.text ?? "" color: item.active ? Colours.palette.m3onTertiaryContainer : Colours.palette.m3onSurface } Loader { asynchronous: true Layout.alignment: Qt.AlignVCenter - active: item.modelData.trailingIcon.length > 0 + active: item.modelData?.trailingIcon.length > 0 visible: active sourceComponent: MaterialIcon { diff --git a/components/controls/StyledSlider.qml b/components/controls/StyledSlider.qml index 91c8040a..751b7586 100644 --- a/components/controls/StyledSlider.qml +++ b/components/controls/StyledSlider.qml @@ -15,6 +15,7 @@ Slider { property bool animateWave property real waveFrequency: 6 property int waveDuration: 1000 + property int radius: Tokens.rounding.medium property color fgColour: enabled ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.38) property color bgColour: enabled ? Colours.palette.m3secondaryContainer : Qt.alpha(Colours.palette.m3onSurface, 0.1) @@ -43,7 +44,7 @@ Slider { implicitHeight: parent.height * (parent.height <= 12 ? opacity : Math.min(opacity * 2, 1)) opacity: Math.min(width, 12) / 12 - radius: Tokens.rounding.medium + radius: root.radius topLeftRadius: Tokens.rounding.extraSmall / 2 bottomLeftRadius: Tokens.rounding.extraSmall / 2 color: root.bgColour @@ -71,9 +72,9 @@ Slider { implicitWidth: 4 implicitHeight: { - const mult = parent.height <= 12 ? 3 : 1.2; - const pressMult = parent.height <= 12 ? 4 : 1.5; - return parent.height * (mouse.pressed ? pressMult : mult); + const t = CUtils.clamp((parent.height - 12) / 16, 0, 1); + const lerp = (a, b) => a + (b - a) * t; + return parent.height * (mouse.pressed ? lerp(3.5, 1.5) : lerp(3, 1.2)); } radius: Tokens.rounding.full @@ -103,7 +104,7 @@ Slider { implicitWidth: root.filledWidth implicitHeight: root.height - radius: Tokens.rounding.medium + radius: root.radius topRightRadius: Tokens.rounding.extraSmall / 2 bottomRightRadius: Tokens.rounding.extraSmall / 2 color: root.fgColour diff --git a/components/filedialog/FileDialog.qml b/components/filedialog/FileDialog.qml index 90dd2bc2..45f814a4 100644 --- a/components/filedialog/FileDialog.qml +++ b/components/filedialog/FileDialog.qml @@ -50,7 +50,10 @@ LazyLoader { implicitWidth: 1000 implicitHeight: 600 + minimumSize.width: 400 + minimumSize.height: 300 color: Colours.tPalette.m3surface + surfaceFormat.opaque: false title: loader.title onVisibleChanged: { diff --git a/components/filedialog/Sidebar.qml b/components/filedialog/Sidebar.qml index 611d44e7..a8d2cf15 100644 --- a/components/filedialog/Sidebar.qml +++ b/components/filedialog/Sidebar.qml @@ -90,7 +90,7 @@ StyledRect { return "folder"; } color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - fontStyle: Tokens.font.icon.large + fontStyle: Tokens.font.icon.medium fill: place.selected ? 1 : 0 Behavior on fill { @@ -104,7 +104,7 @@ StyledRect { Layout.fillWidth: true text: place.modelData color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - font: Tokens.font.body.medium + font: Tokens.font.body.small elide: Text.ElideRight } } diff --git a/components/filedialog/Sizes.qml b/components/filedialog/Sizes.qml index 2ad31f9f..c75202e3 100644 --- a/components/filedialog/Sizes.qml +++ b/components/filedialog/Sizes.qml @@ -4,5 +4,5 @@ import Quickshell Singleton { property int itemWidth: 103 - property int sidebarWidth: 200 + property int sidebarWidth: 230 } diff --git a/components/images/FadeImage.qml b/components/images/FadeImage.qml index ebbb9ab6..a397863b 100644 --- a/components/images/FadeImage.qml +++ b/components/images/FadeImage.qml @@ -6,10 +6,15 @@ Image { id: root property bool hadPrevious + property bool fadingOut + property bool preventInit + property int fadeOutAnim: Anim.FastEffects + property int fadeInAnim: Anim.DefaultEffects + property int fadeInLargeAnim: Anim.StandardLarge function maybeStartInAnim(): void { - if (!opacityInAnim.running && status === Image.Ready) { - opacityInAnim.type = hadPrevious ? Anim.DefaultEffects : Anim.StandardLarge; + if (!preventInit && !opacityInAnim.running && status === Image.Ready) { + opacityInAnim.type = hadPrevious ? fadeInAnim : fadeInLargeAnim; opacityInAnim.start(); } } @@ -26,6 +31,7 @@ Image { opacity: 0 onStatusChanged: maybeStartInAnim() + onPreventInitChanged: maybeStartInAnim() Anim on opacity { id: opacityInAnim @@ -36,11 +42,24 @@ Image { Behavior on source { SequentialAnimation { + ScriptAction { + script: opacityInAnim.stop() + } + PropertyAction { + target: root + property: "fadingOut" + value: true + } Anim { target: root property: "opacity" to: 0 - type: Anim.FastEffects + type: root.fadeOutAnim + } + PropertyAction { + target: root + property: "fadingOut" + value: false } PropertyAction { target: root diff --git a/modules/Shortcuts.qml b/modules/Shortcuts.qml index 9ee79676..f49fa0e0 100644 --- a/modules/Shortcuts.qml +++ b/modules/Shortcuts.qml @@ -4,7 +4,7 @@ import Quickshell.Io import Caelestia import qs.components.misc import qs.services -import qs.modules.controlcenter +import qs.modules.nexus Scope { id: root @@ -15,8 +15,8 @@ Scope { // qmllint disable unresolved-type CustomShortcut { // qmllint enable unresolved-type - name: "controlCenter" - description: "Open control center" + name: "nexus" + description: "Open nexus" onPressed: WindowFactory.create() } @@ -133,7 +133,7 @@ Scope { WindowFactory.create(); } - target: "controlCenter" + target: "nexus" } IpcHandler { diff --git a/modules/bar/popouts/Wrapper.qml b/modules/bar/popouts/Wrapper.qml index d360f30d..97589f9a 100644 --- a/modules/bar/popouts/Wrapper.qml +++ b/modules/bar/popouts/Wrapper.qml @@ -7,7 +7,7 @@ import Quickshell.Wayland import Caelestia.Config import qs.components import qs.services -import qs.modules.controlcenter +import qs.modules.nexus import qs.modules.windowinfo Item { @@ -18,7 +18,7 @@ Item { readonly property alias content: content readonly property alias winfo: winfo - readonly property alias controlCenter: controlCenter + readonly property alias nexus: nexus readonly property real nonAnimWidth: children.find(c => c.shouldBeActive)?.implicitWidth ?? content.implicitWidth readonly property real nonAnimHeight: children.find(c => c.shouldBeActive)?.implicitHeight ?? content.implicitHeight @@ -128,15 +128,24 @@ Item { } Comp { - id: controlCenter + id: nexus shouldBeActive: root.detachedMode === "any" anchors.centerIn: parent - sourceComponent: ControlCenter { - screen: root.screen - active: root.queuedMode - onClose: root.close() + sourceComponent: StyledClippingRect { + radius: Tokens.rounding.extraLarge + implicitWidth: nexusInner.implicitWidth + implicitHeight: nexusInner.implicitHeight + + Nexus { + id: nexusInner + + anchors.fill: parent + nState.screen: root.screen + nState.currentPageIdx: ["appearance", "network", "bluetooth", "audio"].indexOf(root.queuedMode) + onClose: root.close() + } } } diff --git a/modules/dashboard/dash/User.qml b/modules/dashboard/dash/User.qml index cd0a96ef..d8d56128 100644 --- a/modules/dashboard/dash/User.qml +++ b/modules/dashboard/dash/User.qml @@ -202,7 +202,6 @@ Item { anchors.verticalCenterOffset: Math.round(fontInfo.pointSize * 0.1) text: "up " + SysInfo.uptime.split(",").slice(0, 2).join(",") // Max 2 components - font: Tokens.font.label.medium width: Tokens.sizes.dashboard.userWidth - x - Tokens.padding.extraLarge elide: Text.ElideRight } diff --git a/modules/dashboard/media/LyricsInfo.qml b/modules/dashboard/media/LyricsInfo.qml index 9edae499..4091f05a 100644 --- a/modules/dashboard/media/LyricsInfo.qml +++ b/modules/dashboard/media/LyricsInfo.qml @@ -19,7 +19,7 @@ Item { id: blobGroup color: Colours.palette.m3surfaceContainerHighest - smoothing: Tokens.rounding.medium + smoothing: root.Tokens.rounding.medium cornerFill: false Behavior on color { diff --git a/modules/drawers/ContentWindow.qml b/modules/drawers/ContentWindow.qml index cc1bccd5..35d0baf8 100644 --- a/modules/drawers/ContentWindow.qml +++ b/modules/drawers/ContentWindow.qml @@ -39,6 +39,8 @@ StyledWindow { readonly property real shadowOpacity: 0.7 * (1 - fsTransitionProg) readonly property real borderLayoutThickness: hasFullscreen ? 0 : contentItem.Config.border.thickness + property color surfaceColour: Colours.tPalette.m3surface + readonly property int dragMaskPadding: { if (focusGrab.active || panels.popouts.isDetached) return 0; @@ -76,6 +78,10 @@ StyledWindow { Anim {} } + Behavior on surfaceColour { + CAnim {} + } + Region { id: emptyRegion @@ -129,7 +135,7 @@ StyledWindow { Item { anchors.fill: parent - opacity: Colours.transparency.enabled ? Colours.transparency.base : 1 + opacity: root.surfaceColour.a layer.enabled: true layer.effect: MultiEffect { shadowEnabled: true @@ -140,12 +146,8 @@ StyledWindow { BlobGroup { id: blobGroup - color: Colours.palette.m3surface + color: root.surfaceColour smoothing: root.contentItem.Config.border.smoothing - - Behavior on color { - CAnim {} - } } BlobInvertedRect { diff --git a/modules/lock/Center.qml b/modules/lock/Center.qml index 30b3ab59..c96c5f0c 100644 --- a/modules/lock/Center.qml +++ b/modules/lock/Center.qml @@ -34,8 +34,8 @@ ColumnLayout { ProfilePic { Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Tokens.spacing.largeIncreased - Layout.bottomMargin: Tokens.spacing.medium + Layout.topMargin: Tokens.spacing.extraExtraLarge * root.centerScale + Layout.bottomMargin: Tokens.spacing.extraLarge * root.centerScale centerWidth: root.centerWidth } diff --git a/modules/nexus/NavPane.qml b/modules/nexus/NavPane.qml new file mode 100644 index 00000000..a5fb94e8 --- /dev/null +++ b/modules/nexus/NavPane.qml @@ -0,0 +1,26 @@ +import "navpane" +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.modules.nexus + +ColumnLayout { + id: root + + required property NexusState nState + + spacing: Tokens.spacing.large + + SearchBar { + Layout.fillWidth: true + nState: root.nState + } + + NavLocations { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: -topMargin + Layout.bottomMargin: -bottomMargin + nState: root.nState + } +} diff --git a/modules/nexus/Nexus.qml b/modules/nexus/Nexus.qml new file mode 100644 index 00000000..90035bc3 --- /dev/null +++ b/modules/nexus/Nexus.qml @@ -0,0 +1,109 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Caelestia.Blobs +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus + +Item { + id: root + + readonly property NexusState nState: NexusState { + id: nState + + onClose: root.close() + } + property color blobColour: Colours.tPalette.m3surfaceContainerLow + + signal close + + implicitWidth: implicitHeight * Tokens.sizes.nexus.ratio + implicitHeight: nState.screen.height * Tokens.sizes.nexus.heightMult + + Behavior on blobColour { + CAnim {} + } + + BlobGroup { + id: blobGroup + + smoothing: root.Tokens.rounding.medium + color: root.blobColour + } + + BlobInvertedRect { + anchors.fill: parent + group: blobGroup + opacity: root.blobColour.a + radius: Tokens.rounding.large + + borderLeft: navPane.width + navPane.anchors.margins * 2 + borderRight: Tokens.padding.medium + borderTop: Tokens.padding.medium + borderBottom: Tokens.padding.medium + } + + BlobRect { + id: windowBtnRect + + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: root.nState.isWindow ? 0 : Tokens.padding.extraSmall + + group: blobGroup + opacity: root.blobColour.a + radius: Tokens.rounding.medium + + implicitWidth: windowBtn.implicitWidth + (root.nState.isWindow ? Tokens.padding.extraSmall : Tokens.padding.small) * 2 + implicitHeight: windowBtn.implicitHeight + (root.nState.isWindow ? Tokens.padding.extraSmall : Tokens.padding.small) + } + + IconButton { + id: windowBtn + + anchors.centerIn: windowBtnRect + icon: nState.isWindow ? "close" : "pip" + type: IconButton.Text + label.fill: 0 + inactiveOnColour: hovered ? nState.isWindow ? Colours.palette.m3error : Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + stateLayer.opacity: 0 + onClicked: { + if (!nState.isWindow) + WindowFactory.create(); + root.close(); + } + + label.scale: pressed ? 0.8 : 1 + label.renderType: Text.QtRendering + + Behavior on label.scale { + Anim {} + } + } + + NavPane { + id: navPane + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Tokens.padding.large + + nState: nState + width: Math.min(Tokens.sizes.nexus.maxNavWidth, Math.round(root.width / 3)) + } + + Pages { + anchors.left: navPane.right + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: navPane.anchors.margins + anchors.margins + anchors.margins: Tokens.padding.extraLarge + + nState: nState + } +} diff --git a/modules/nexus/NexusState.qml b/modules/nexus/NexusState.qml new file mode 100644 index 00000000..818f35c0 --- /dev/null +++ b/modules/nexus/NexusState.qml @@ -0,0 +1,30 @@ +import QtQuick +import Quickshell +import Quickshell.Bluetooth + +QtObject { + property ShellScreen screen + property bool isWindow + property int currentPageIdx + property list subPageIdxStack + property bool searchOpen + + property string selectedWallpaperCategory + property BluetoothDevice selectedBtDevice + + signal close + signal subPageOpened(idx: int) + signal subPageClosed + + function openSubPage(idx: int): void { + subPageIdxStack.push(idx); + subPageOpened(idx); + } + + function closeSubPage(): void { + subPageClosed(); + subPageIdxStack.pop(); + } + + onCurrentPageIdxChanged: subPageIdxStack.length = 0 +} diff --git a/modules/nexus/PageCompRegistry.qml b/modules/nexus/PageCompRegistry.qml new file mode 100644 index 00000000..8c48110b --- /dev/null +++ b/modules/nexus/PageCompRegistry.qml @@ -0,0 +1,180 @@ +pragma Singleton + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common +import qs.modules.nexus.pages +import qs.modules.nexus.pages.audio +import qs.modules.nexus.pages.bluetooth +import qs.modules.nexus.pages.panels +import qs.modules.nexus.pages.wallandstyle +import qs.modules.nexus.pages.panels.taskbar + +QtObject { + id: root + + readonly property list pageComps: [ + // Appearance + Component { + // Wallpaper & style + StackPage { + Component { + WallpaperAndStyle {} + } + Component { + WallpaperSelect {} + } + Component { + WallpaperCategory {} + } + Component { + ColourSelect {} + } + } + }, + + // Connectivity + Component { + // Network + StackPage { + Component { + NetworkPage {} + } + } + }, + Component { + // Bluetooth + StackPage { + Component { + BluetoothPage {} + } + Component { + BtDeviceInfo {} + } + Component { + BluetoothPairing {} + } + } + }, + Component { + // Audio + StackPage { + Component { + AudioPage {} + } + Component { + AppVolumes {} + } + } + }, + + // System + Component { + PlaceholderComp {} + }, + Component { + PlaceholderComp {} + }, + + // Shell + Component { + // Panels + StackPage { + Component { + PanelsPage {} + } + Component { + DashboardPanel {} + } + Component { + TaskbarPanel {} + } + Component { + LauncherPanel {} + } + Component { + SidebarPanel {} + } + + // Taskbar component sub-pages + Component { + BarWorkspaces {} + } + Component { + BarActiveWindow {} + } + Component { + BarTray {} + } + Component { + BarStatusIcons {} + } + Component { + BarClock {} + } + } + }, + Component { + // Services + StackPage { + Component { + ServicesPage {} + } + } + }, + Component { + // Language & region + StackPage { + Component { + LanguageAndRegion {} + } + } + }, + + // About + Component { + StackPage { + Component { + AboutPage {} + } + } + } + ] + + readonly property Component placeholderComp: Component { + PlaceholderComp {} + } + + component PlaceholderComp: Item { + property NexusState nState // To avoid the warning from non-existent property + + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.padding.extraSmall + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "handyman" + color: Colours.palette.m3outlineVariant + font: Tokens.font.icon.extraLarge + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Page under construction") + color: Colours.palette.m3outlineVariant + font: Tokens.font.title.large + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("This page will be available in a future update.") + color: Colours.palette.m3outlineVariant + font: Tokens.font.body.large + } + } + } +} diff --git a/modules/nexus/PageRegistry.qml b/modules/nexus/PageRegistry.qml new file mode 100644 index 00000000..ea3ad71e --- /dev/null +++ b/modules/nexus/PageRegistry.qml @@ -0,0 +1,87 @@ +pragma Singleton + +import QtQuick + +QtObject { + id: root + + readonly property list pages: [ + // Appearance + { + label: qsTr("Wallpaper & style"), + icon: "palette", + description: qsTr("Wallpaper, fonts, colours"), + category: "appearance" + }, + + // Connectivity + // TODO + // { + // label: qsTr("Display"), + // icon: "monitor", + // description: qsTr("Output configuration"), + // category: "connectivity" + // }, + { + label: qsTr("Network"), + icon: "wifi", + description: qsTr("Wi-Fi, ethernet"), + category: "connectivity" + }, + { + label: qsTr("Connected devices"), + icon: "devices_other", + description: qsTr("Bluetooth, pairing"), + category: "connectivity", + noFill: true + }, + { + label: qsTr("Audio"), + icon: "volume_up", + description: qsTr("App volumes, sound devices"), + category: "connectivity" + }, + + // System + { + label: qsTr("Updates"), + icon: "update", + description: qsTr("System updates"), + category: "system" + }, + { + label: qsTr("Plugins"), + icon: "extension", + description: qsTr("Manage plugins"), + category: "system" + }, + + // Shell + { + label: qsTr("Panels"), + icon: "dock_to_bottom", + description: qsTr("Dashboard, taskbar, launcher, sidebar"), + category: "shell" + }, + { + label: qsTr("Services"), + icon: "build", + description: qsTr("Poll intervals, lyrics backend"), + category: "shell" + }, + { + label: qsTr("Language & region"), + icon: "globe", + description: qsTr("UI language, weather location, display units"), + category: "shell" + }, + + // About + { + label: qsTr("About"), + icon: "info", + description: qsTr("System information, credits"), + category: "about" + }, + ] +} diff --git a/modules/nexus/Pages.qml b/modules/nexus/Pages.qml new file mode 100644 index 00000000..655cd9cf --- /dev/null +++ b/modules/nexus/Pages.qml @@ -0,0 +1,95 @@ +import QtQuick +import Caelestia.Config +import qs.components +import qs.modules.nexus + +Item { + id: root + + required property NexusState nState + + property int lastPageIdx + property int animOff + property Item currentItem + + function loadPage(idx: int): void { + if (currentItem) + currentItem.destroy(); + + const comp = PageCompRegistry.pageComps[idx] ?? PageCompRegistry.placeholderComp; + const incubator = comp.incubateObject(container, { + nState + }); + + const attach = () => { + incubator.object.anchors.fill = container; + currentItem = incubator.object; + }; + + if (incubator.status === Component.Ready) + attach(); + else + incubator.onStatusChanged = status => { + if (status === Component.Ready) + attach(); + }; + } + + Item { + id: container + + anchors.fill: parent + layer.enabled: opacity < 1 + Component.onCompleted: root.loadPage(root.nState.currentPageIdx) + } + + Connections { + function onCurrentPageIdxChanged(): void { + switchAnim.complete(); + root.animOff = root.Tokens.padding.extraLarge * (root.nState.currentPageIdx > root.lastPageIdx ? 1 : -1); + switchAnim.start(); + root.lastPageIdx = root.nState.currentPageIdx; + } + + target: root.nState + } + + SequentialAnimation { + id: switchAnim + + Anim { + target: container + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + ScriptAction { + script: root.loadPage(root.nState.currentPageIdx) + } + PropertyAction { + target: container.anchors + property: "topMargin" + value: root.animOff + } + PropertyAction { + target: container.anchors + property: "bottomMargin" + value: -root.animOff + } + ParallelAnimation { + Anim { + target: container + property: "opacity" + from: 0 + to: 1 + type: Anim.SlowEffects + } + Anim { + target: container.anchors + properties: "topMargin,bottomMargin" + to: 0 + type: Anim.SlowEffects + } + } + } +} diff --git a/modules/nexus/WindowFactory.qml b/modules/nexus/WindowFactory.qml new file mode 100644 index 00000000..ab26d15f --- /dev/null +++ b/modules/nexus/WindowFactory.qml @@ -0,0 +1,60 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus + +Singleton { + id: root + + function create(parent: Item, props: var): void { + nexusComp.createObject(parent ?? dummy, props); + } + + QtObject { + id: dummy + } + + Component { + id: nexusComp + + FloatingWindow { + id: win + + color: Colours.tPalette.m3surface + surfaceFormat.opaque: false + + onVisibleChanged: { + if (!visible) + destroy(); + } + + implicitWidth: nexus.implicitWidth + implicitHeight: nexus.implicitHeight + + minimumSize.width: contentItem.Tokens.sizes.nexus.minWidth + minimumSize.height: contentItem.Tokens.sizes.nexus.minHeight + + contentItem.Config.screen: screen.name + contentItem.Tokens.screen: screen.name + + title: qsTr("Nexus — %1").arg(PageRegistry.pages[nexus.nState.currentPageIdx].label) + + Nexus { + id: nexus + + anchors.fill: parent + nState.screen: win.screen + nState.isWindow: true + onClose: win.destroy() + } + + Behavior on color { + CAnim {} + } + } + } +} diff --git a/modules/nexus/common/AnimatedLogo.qml b/modules/nexus/common/AnimatedLogo.qml new file mode 100644 index 00000000..6d2aaa00 --- /dev/null +++ b/modules/nexus/common/AnimatedLogo.qml @@ -0,0 +1,513 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import QtQuick.Shapes +import qs.components +import qs.services + +Item { + id: root + + property real blurAmount: skipIntroAnimation ? 0.0 : 1.0 + property bool skipIntroAnimation: false + + property real star1Opacity: skipIntroAnimation ? 1.0 : 0.0 + property real star2Opacity: skipIntroAnimation ? 1.0 : 0.0 + property real star3Opacity: skipIntroAnimation ? 1.0 : 0.0 + + property real star1Scale: skipIntroAnimation ? 1.0 : 0.0 + property real star2Scale: skipIntroAnimation ? 1.0 : 0.0 + property real star3Scale: skipIntroAnimation ? 1.0 : 0.0 + + readonly property alias topShape: topShape + readonly property alias bottomShape: bottomShape + readonly property alias star1: star1 + readonly property alias star2: star2 + readonly property alias star3: star3 + + signal animationCompleted + + implicitWidth: 128 + implicitHeight: 90.38 + + Item { + id: logo + + readonly property real designWidth: 128 + readonly property real designHeight: 90.38 + + property color topColour: Colours.palette.m3primary + property color bottomColour: Colours.palette.m3onSurface + + implicitWidth: designWidth + implicitHeight: designHeight + + transformOrigin: Item.Center + scale: root.skipIntroAnimation ? 1.0 : 0.0 + opacity: root.skipIntroAnimation ? 1.0 : 0.0 + rotation: 0.0 + + layer.enabled: root.blurAmount > 0 + layer.effect: MultiEffect { + blurEnabled: true + blur: root.blurAmount + blurMax: 60 + } + + Component.onCompleted: { + root.star1.opacity = Qt.binding(() => root.star1Opacity); + root.star1.scale = Qt.binding(() => root.star1Scale); + root.star2.opacity = Qt.binding(() => root.star2Opacity); + root.star2.scale = Qt.binding(() => root.star2Scale); + root.star3.opacity = Qt.binding(() => root.star3Opacity); + root.star3.scale = Qt.binding(() => root.star3Scale); + } + + Behavior on topColour { + CAnim {} + } + + Behavior on bottomColour { + CAnim {} + } + + Shape { + id: topShape + + anchors.centerIn: parent + width: logo.designWidth + height: logo.designHeight + scale: Math.min(logo.width / width, logo.height / height) + transformOrigin: Item.Center + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: logo.topColour + strokeColor: "transparent" + + PathSvg { + path: "m42.56,42.96c-7.76,1.6-16.36,4.22-22.44,6.22-.49.16-.88-.44-.53-.82,5.37-5.85,9.66-13.3,9.66-13.3,8.66-14.67,22.97-23.51,39.85-21.14,6.47.91,12.33,3.38,17.26,6.98.99.72,1.14,2.14.31,3.04-.4.44-.95.67-1.51.67-.34,0-.69-.09-1-.26-3.21-1.84-6.82-2.69-10.71-3.24-13.1-1.84-25.41,4.75-31.06,15.83-.94,1.84-.61,3.81.45,5.21.22.3.07.72-.29.8Z" + } + } + } + + Shape { + id: bottomShape + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: logo.bottomColour + strokeColor: "transparent" + + PathSvg { + path: "m103.02,51.8c-.65.11-1.26-.37-1.28-1.03-.06-1.96.15-3.89-.2-5.78-.28-1.48-1.66-2.5-3.16-2.34h-.05c-6.53.73-24.63,3.1-48,9.32-6.89,1.83-9.83,10-5.67,15.79,4.62,6.44,11.84,10.93,20.41,12.13,11.82,1.66,22.99-3.36,29.21-12.65.54-.81,1.54-1.17,2.47-.86.91.3,1.47,1.15,1.47,2.04,0,.33-.08.66-.24.98-7.23,14.21-22.91,22.95-39.59,20.6-7.84-1.1-14.8-4.5-20.28-9.43,0,0,0,0-.02-.01-7.28-5.14-14.7-9.99-27.24-11.98-18.82-2.98-9.53-8.75.46-13.78,7.36-3.13,25.17-7.9,36.24-10.73.16-.03.31-.06.47-.1,1.52-.4,3.2-.83,5.02-1.29,1.06-.26,1.93-.48,2.58-.64.09-.02.18-.04.26-.06.31-.08.56-.14.73-.18.03,0,.06-.01.08-.02.03,0,.05-.01.07-.02.02,0,.04,0,.06-.01.01,0,.03,0,.04-.01,0,0,.02,0,.03,0,.01,0,.02,0,.02,0,10.62-2.58,24.63-5.62,37.74-7.34,1.02-.13,2.03-.26,3.03-.37,7.49-.87,14.58-1.26,20.42-.81,25.43,1.95-4.71,16.77-15.12,18.61Z" + } + } + } + + Shape { + id: star1 + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + opacity: 0.0 + + ShapePath { + fillColor: logo.topColour + strokeColor: "transparent" + + PathSvg { + path: "m98.12.06c-.29,2.08-1.72,8.42-8.36,9.19-.09,0-.09.13,0,.14,6.64.78,8.07,7.11,8.36,9.19.01.08.13.08.14,0,.29-2.08,1.72-8.42,8.36-9.19.09,0,.09-.13,0-.14-6.64-.78-8.07-7.11-8.36-9.19-.01-.08-.13-.08-.14,0Z" + } + } + } + + Shape { + id: star2 + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + opacity: 0.0 + + ShapePath { + fillColor: logo.topColour + strokeColor: "transparent" + + PathSvg { + path: "m113.36,15.5c-.22,1.29-1.08,4.35-4.38,4.87-.08.01-.08.13,0,.14,3.3.52,4.17,3.58,4.38,4.87.01.08.13.08.14,0,.22-1.29,1.08-4.35,4.38-4.87.08-.01.08-.13,0-.14-3.3-.52-4.17-3.58-4.38-4.87-.01-.08-.13-.08-.14,0Z" + } + } + } + + Shape { + id: star3 + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + opacity: 0.0 + + ShapePath { + fillColor: logo.topColour + strokeColor: "transparent" + + PathSvg { + path: "m112.69,65.22c-.19,1.01-.86,3.15-3.2,3.57-.08.01-.08.13,0,.14,2.34.42,3.01,2.56,3.2,3.57.01.08.13.08.14,0,.19-1.01.86-3.15,3.2-3.57.08-.01.08-.13,0-.14-2.34-.42-3.01-2.56-3.2-3.57-.01-.08-.13-.08-.14,0Z" + } + } + } + } + + SequentialAnimation { + running: !root.skipIntroAnimation + onFinished: root.animationCompleted() + + ParallelAnimation { + SequentialAnimation { + NumberAnimation { + target: logo + property: "rotation" + from: 0 + to: 750 + duration: 1000 + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: logo + property: "rotation" + from: 750 + to: 710 + duration: 300 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: logo + property: "rotation" + from: 710 + to: 725 + duration: 350 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: logo + property: "rotation" + from: 725 + to: 720 + duration: 250 + easing.type: Easing.OutQuad + } + + ScriptAction { + script: logo.rotation = 0 + } + } + + SequentialAnimation { + NumberAnimation { + target: logo + property: "scale" + from: 0.0 + to: 1.08 + duration: 1000 + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: logo + property: "scale" + from: 1.08 + to: 0.96 + duration: 200 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: logo + property: "scale" + from: 0.96 + to: 1.0 + duration: 250 + easing.type: Easing.OutBack + easing.overshoot: 1.05 + } + } + + NumberAnimation { + target: logo + property: "opacity" + from: 0.0 + to: 1.0 + duration: 600 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root + property: "blurAmount" + from: 1.0 + to: 0.0 + duration: 900 + easing.type: Easing.OutCubic + } + + SequentialAnimation { + PauseAnimation { + duration: 1100 + } + + ParallelAnimation { + NumberAnimation { + target: root + property: "star1Opacity" + from: 0.0 + to: 1.0 + duration: 700 + easing.type: Easing.InOutQuad + } + + SequentialAnimation { + NumberAnimation { + target: root + property: "star1Scale" + from: 0.0 + to: 1.08 + duration: 500 + easing.type: Easing.OutQuad + } + + NumberAnimation { + target: root + property: "star1Scale" + from: 1.08 + to: 1.0 + duration: 400 + easing.type: Easing.InOutQuad + } + } + } + } + + SequentialAnimation { + PauseAnimation { + duration: 1250 + } + + ParallelAnimation { + NumberAnimation { + target: root + property: "star2Opacity" + from: 0.0 + to: 1.0 + duration: 700 + easing.type: Easing.InOutQuad + } + + SequentialAnimation { + NumberAnimation { + target: root + property: "star2Scale" + from: 0.0 + to: 1.08 + duration: 500 + easing.type: Easing.OutQuad + } + + NumberAnimation { + target: root + property: "star2Scale" + from: 1.08 + to: 1.0 + duration: 400 + easing.type: Easing.InOutQuad + } + } + } + } + + SequentialAnimation { + PauseAnimation { + duration: 1400 + } + + ParallelAnimation { + NumberAnimation { + target: root + property: "star3Opacity" + from: 0.0 + to: 1.0 + duration: 700 + easing.type: Easing.InOutQuad + } + + SequentialAnimation { + NumberAnimation { + target: root + property: "star3Scale" + from: 0.0 + to: 1.08 + duration: 500 + easing.type: Easing.OutQuad + } + + NumberAnimation { + target: root + property: "star3Scale" + from: 1.08 + to: 1.0 + duration: 400 + easing.type: Easing.InOutQuad + } + } + } + } + } + } + + SequentialAnimation { + running: true + loops: Animation.Infinite + + PauseAnimation { + duration: 2500 + } + + ParallelAnimation { + SequentialAnimation { + loops: Animation.Infinite + + NumberAnimation { + target: root.star1 + property: "y" + from: root.star1.y + to: root.star1.y - 5 + duration: 2500 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root.star1 + property: "y" + from: root.star1.y - 5 + to: root.star1.y + duration: 2500 + easing.type: Easing.InOutQuad + } + } + + SequentialAnimation { + loops: Animation.Infinite + + NumberAnimation { + target: root.star2 + property: "y" + from: root.star2.y + to: root.star2.y + 5 + duration: 3000 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root.star2 + property: "y" + from: root.star2.y + 5 + to: root.star2.y + duration: 3000 + easing.type: Easing.InOutQuad + } + } + + SequentialAnimation { + loops: Animation.Infinite + + NumberAnimation { + target: root.star3 + property: "y" + from: root.star3.y + to: root.star3.y - 5 + duration: 2800 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root.star3 + property: "y" + from: root.star3.y - 5 + to: root.star3.y + duration: 2800 + easing.type: Easing.InOutQuad + } + } + + SequentialAnimation { + loops: Animation.Infinite + + NumberAnimation { + target: root.star1 + property: "scale" + from: 1.0 + to: 1.08 + duration: 2500 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root.star1 + property: "scale" + from: 1.08 + to: 1.0 + duration: 2500 + easing.type: Easing.InOutQuad + } + } + + SequentialAnimation { + loops: Animation.Infinite + + NumberAnimation { + target: root.star2 + property: "scale" + from: 1.0 + to: 1.12 + duration: 3000 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root.star2 + property: "scale" + from: 1.12 + to: 1.0 + duration: 3000 + easing.type: Easing.InOutQuad + } + } + + SequentialAnimation { + loops: Animation.Infinite + + NumberAnimation { + target: root.star3 + property: "scale" + from: 1.0 + to: 1.08 + duration: 2800 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root.star3 + property: "scale" + from: 1.08 + to: 1.0 + duration: 2800 + easing.type: Easing.InOutQuad + } + } + } + } +} diff --git a/modules/nexus/common/AudioDeviceList.qml b/modules/nexus/common/AudioDeviceList.qml new file mode 100644 index 00000000..8aacb579 --- /dev/null +++ b/modules/nexus/common/AudioDeviceList.qml @@ -0,0 +1,97 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common + +ItemList { + id: root + + property var nodes: [] + property int currentId: -1 + property string iconName: "speaker" + + signal selected(node: PwNode) + + last: true + showList: true + + model: ScriptModel { + values: [...root.nodes].sort((a, b) => (a.description || a.name || "").localeCompare(b.description || b.name || "")) + } + + delegate: Item { + id: device + + required property PwNode modelData + required property int index + readonly property bool active: device.modelData?.id === root.currentId + + anchors.left: root.list.contentItem.left + anchors.right: root.list.contentItem.right + implicitHeight: deviceLayout.implicitHeight + deviceLayout.anchors.margins * 2 + + StateLayer { + radius: Tokens.rounding.extraSmall + bottomLeftRadius: device.index === root?.list.count - 1 ? Tokens.rounding.extraLarge : radius + bottomRightRadius: device.index === root?.list.count - 1 ? Tokens.rounding.extraLarge : radius + onClicked: root.selected(device.modelData) + } + + RowLayout { + id: deviceLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: devIcon.implicitHeight + Tokens.padding.small * 2 + radius: Tokens.rounding.full + color: device.active ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer + + MaterialIcon { + id: devIcon + + anchors.centerIn: parent + text: root.iconName + color: device.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer + font: Tokens.font.icon.medium + fill: device.active ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + } + + StyledText { + Layout.fillWidth: true + text: device.modelData?.description || device.modelData?.name || qsTr("Unknown") + font: Tokens.font.body.small + elide: Text.ElideRight + } + + MaterialIcon { + text: "check" + color: Colours.palette.m3primary + font: Tokens.font.icon.medium + opacity: device.active ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + } + } +} diff --git a/modules/nexus/common/ConnectedRect.qml b/modules/nexus/common/ConnectedRect.qml new file mode 100644 index 00000000..df5b21ae --- /dev/null +++ b/modules/nexus/common/ConnectedRect.qml @@ -0,0 +1,15 @@ +import QtQuick +import Caelestia.Config +import qs.components +import qs.services + +StyledRect { + property bool first + property bool last + + color: Colours.tPalette.m3surfaceContainer + topLeftRadius: first ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall + topRightRadius: first ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall + bottomLeftRadius: last ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall + bottomRightRadius: last ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall +} diff --git a/modules/nexus/common/InfoRow.qml b/modules/nexus/common/InfoRow.qml new file mode 100644 index 00000000..f06094f8 --- /dev/null +++ b/modules/nexus/common/InfoRow.qml @@ -0,0 +1,59 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common + +ConnectedRect { + id: root + + property alias label: label.text + property string subtext + property alias value: value.text + + Layout.fillWidth: true + implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins * 2 + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + visible: root.subtext + text: root.subtext + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + + StyledText { + id: value + + Layout.maximumWidth: root.width / 2 + horizontalAlignment: Text.AlignRight + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + elide: Text.ElideRight + } + } +} diff --git a/modules/nexus/common/ItemList.qml b/modules/nexus/common/ItemList.qml new file mode 100644 index 00000000..debfdcef --- /dev/null +++ b/modules/nexus/common/ItemList.qml @@ -0,0 +1,121 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services + +ConnectedRect { + id: root + + property bool showList + property string placeholderIcon + property string placeholderText + property int extraHeight + + property alias model: list.model + property alias delegate: list.delegate + readonly property alias list: list + + Layout.fillWidth: true + implicitHeight: (showList && list.count > 0 ? list.contentHeight : placeholder.implicitHeight + Tokens.padding.extraLarge * 2) + extraHeight + color: Colours.tPalette.m3surfaceContainer + clip: true + + Behavior on implicitHeight { + Anim {} + } + + Loader { + id: placeholder + + anchors.centerIn: parent + active: opacity > 0 + opacity: root.showList && list.count > 0 ? 0 : 1 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.extraSmall + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: root.placeholderIcon + color: Colours.palette.m3outline + font: Tokens.font.icon.large + animate: true + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.placeholderText + color: Colours.palette.m3outline + font: Tokens.font.body.large + animate: true + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + ListView { + id: list + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + + spacing: 0 + interactive: false + opacity: root.showList ? 1 : 0 + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + type: Anim.DefaultEffects + } + } + + remove: Transition { + Anim { + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + } + + move: Transition { + Anim { + property: "opacity" + to: 1 + type: Anim.DefaultEffects + } + Anim { + property: "y" + } + } + + displaced: Transition { + Anim { + property: "opacity" + to: 1 + type: Anim.DefaultEffects + } + Anim { + property: "y" + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } +} diff --git a/modules/nexus/common/NavRow.qml b/modules/nexus/common/NavRow.qml new file mode 100644 index 00000000..f8f06eff --- /dev/null +++ b/modules/nexus/common/NavRow.qml @@ -0,0 +1,72 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common + +ConnectedRect { + id: root + + property alias icon: icon.text + property alias label: label.text + property alias status: status.text + + signal clicked + + Layout.fillWidth: true + implicitHeight: navLayout.implicitHeight + navLayout.anchors.margins * 2 + + StateLayer { + onClicked: root.clicked() + } + + RowLayout { + id: navLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + MaterialIcon { + id: icon + + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + id: status + + Layout.fillWidth: true + visible: text + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + animate: true + } + } + + MaterialIcon { + text: "chevron_right" + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + } + } +} diff --git a/modules/nexus/common/PageBase.qml b/modules/nexus/common/PageBase.qml new file mode 100644 index 00000000..82df2215 --- /dev/null +++ b/modules/nexus/common/PageBase.qml @@ -0,0 +1,61 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.containers +import qs.components.controls +import qs.services +import qs.modules.nexus + +ColumnLayout { + id: root + + required property string title + required property NexusState nState + property bool isSubPage + readonly property int cappedWidth: Math.min(800, width) + + default property Item contentChild + + spacing: Tokens.spacing.extraLargeIncreased + + RowLayout { + spacing: Tokens.spacing.largeIncreased + + Loader { + visible: active + active: root.isSubPage + asynchronous: true + sourceComponent: IconButton { + icon: "arrow_back" + font: Tokens.font.icon.medium + type: IconButton.Tonal + isRound: true + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurfaceVariant + onClicked: root.nState.closeSubPage() + } + } + + StyledText { + text: root.title + font: Tokens.font.title.large + } + } + + VerticalFadeFlickable { + id: flickable + + Layout.fillWidth: true + Layout.fillHeight: true + + Layout.topMargin: -topMargin + topMargin: Tokens.padding.large + bottomMargin: Tokens.padding.extraLarge + + contentHeight: root.contentChild?.implicitHeight ?? 0 + contentItem.children: [root.contentChild] + } +} diff --git a/modules/nexus/common/SectionHeader.qml b/modules/nexus/common/SectionHeader.qml new file mode 100644 index 00000000..442990d5 --- /dev/null +++ b/modules/nexus/common/SectionHeader.qml @@ -0,0 +1,18 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services + +StyledText { + property bool first + + Layout.fillWidth: true + Layout.topMargin: first ? 0 : Tokens.spacing.largeIncreased - ((parent as ColumnLayout).spacing ?? 0) + Layout.bottomMargin: Tokens.spacing.extraSmall + Layout.leftMargin: Tokens.padding.small + + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.label.medium + elide: Text.ElideRight +} diff --git a/modules/nexus/common/SelectRow.qml b/modules/nexus/common/SelectRow.qml new file mode 100644 index 00000000..2c8b00da --- /dev/null +++ b/modules/nexus/common/SelectRow.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.common + +ConnectedRect { + id: root + + property alias label: label.text + property string subtext + property alias menuItems: splitButton.menuItems + property alias active: splitButton.active + property alias fallbackText: splitButton.fallbackText + property alias fallbackIcon: splitButton.fallbackIcon + property alias menuOnTop: splitButton.menuOnTop + + signal selected(item: MenuItem) + + implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins * 2 + clip: false + z: splitButton.expanded ? 1 : 0 + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + visible: root.subtext + text: root.subtext + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + + SplitButton { + id: splitButton + + type: SplitButton.Tonal + stateLayer.onClicked: splitButton.expanded = !splitButton.expanded + menu.onItemSelected: item => root.selected(item) + } + } +} diff --git a/modules/nexus/common/SliderRow.qml b/modules/nexus/common/SliderRow.qml new file mode 100644 index 00000000..21cbe545 --- /dev/null +++ b/modules/nexus/common/SliderRow.qml @@ -0,0 +1,88 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.common + +ConnectedRect { + id: root + + property alias icon: icon.text + property alias label: label.text + property alias valueLabel: valueLabel.text + property real value + + signal moved(value: real) + + implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins + rowLayout.anchors.topMargin + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.largeIncreased + anchors.topMargin: Tokens.padding.large + spacing: Tokens.spacing.medium + + MaterialIcon { + id: icon + + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + id: valueLabel + + color: Colours.palette.m3outline + font: Tokens.font.body.small + } + } + + CustomMouseArea { + function onWheel(event: WheelEvent): void { + const step = GlobalConfig.services.audioIncrement; + if (event.angleDelta.y > 0) + root.moved(Math.min(1, root.value + step)); + else if (event.angleDelta.y < 0) + root.moved(Math.max(0, root.value - step)); + } + + Layout.fillWidth: true + implicitHeight: Tokens.padding.medium * 2 + + StyledSlider { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: parent.implicitHeight + + radius: Tokens.rounding.small + value: root.value + enabled: root.enabled + onInteraction: v => root.moved(v) + } + } + } + } +} diff --git a/modules/nexus/common/StackPage.qml b/modules/nexus/common/StackPage.qml new file mode 100644 index 00000000..f104652e --- /dev/null +++ b/modules/nexus/common/StackPage.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtQuick.Controls +import Caelestia.Config +import qs.components +import qs.modules.nexus + +StackView { + id: root + + required property NexusState nState + default property list pages + readonly property int animMovement: Tokens.padding.extraExtraLarge * 2 + + function openSubPage(idx: int, immediate: bool): void { + const page = pages[idx]; + if (page) { + push(page, { + nState + }, immediate ? StackView.Immediate : StackView.PushTransition); + } else { + console.warn(logCat, "Attempted to open invalid sub-page index", idx); + nState.closeSubPage(); + } + } + + clip: busy + + Component.onCompleted: { + openSubPage(0, true); + for (const page of nState.subPageIdxStack) + openSubPage(page, true); + } + + pushEnter: Transition { + SequentialAnimation { + PropertyAction { + property: "opacity" + value: 0 + } + PauseAnimation { + duration: Tokens.anim.durations.expressiveDefaultEffects + } + ParallelAnimation { + Anim { + property: "opacity" + to: 1 + type: Anim.SlowEffects + } + Anim { + property: "x" + from: root.animMovement + to: 0 + type: Anim.SlowEffects + } + } + } + } + + pushExit: Transition { + Anim { + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + } + + popEnter: Transition { + SequentialAnimation { + PropertyAction { + property: "opacity" + value: 0 + } + PauseAnimation { + duration: Tokens.anim.durations.expressiveDefaultEffects + } + ParallelAnimation { + Anim { + property: "opacity" + to: 1 + type: Anim.SlowEffects + } + Anim { + property: "x" + from: -root.animMovement + to: 0 + type: Anim.SlowEffects + } + } + } + } + + popExit: Transition { + Anim { + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + } + + LoggingCategory { + id: logCat + + name: "caelestia.nexus" + defaultLogLevel: LoggingCategory.Info + } + + Connections { + function onSubPageOpened(idx: int): void { + root.openSubPage(idx, false); + } + + function onSubPageClosed(): void { + if (root.depth < root.nState.subPageIdxStack.length) { + console.log(logCat, "Attempted to close page while depth < stack depth. Ignoring."); + return; + } + root.pop(); + } + + target: root.nState + } +} diff --git a/modules/nexus/common/StepperRow.qml b/modules/nexus/common/StepperRow.qml new file mode 100644 index 00000000..f15afa83 --- /dev/null +++ b/modules/nexus/common/StepperRow.qml @@ -0,0 +1,64 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.common + +ConnectedRect { + id: root + + property alias label: label.text + property string subtext + property real value + property real from: 0 + property real to: 99 + property real stepSize: 1 + + signal moved(value: real) + + implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins * 2 + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + visible: root.subtext + text: root.subtext + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + + CustomSpinBox { + min: root.from + max: root.to + step: root.stepSize + value: root.value + onValueModified: v => root.moved(v) + } + } +} diff --git a/modules/nexus/common/ToggleRow.qml b/modules/nexus/common/ToggleRow.qml new file mode 100644 index 00000000..1afdcdac --- /dev/null +++ b/modules/nexus/common/ToggleRow.qml @@ -0,0 +1,79 @@ +import QtQuick +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +StyledSwitch { + id: root + + property string subtext + property alias first: bg.first + property alias last: bg.last + readonly property alias bg: bg + + horizontalPadding: Tokens.padding.largeIncreased + verticalPadding: Tokens.padding.medium + font: Tokens.font.body.small + + implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 + implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 + cLayer: 2 + + indicator.anchors.verticalCenter: verticalCenter + indicator.anchors.right: right + indicator.anchors.rightMargin: root.horizontalPadding + + onPressed: stateLayer.press(stateLayer.mouseX, stateLayer.mouseY) + + background: ConnectedRect { + id: bg + + StateLayer { + id: stateLayer + + manualPressOverride: root.pressed + } + } + + contentItem: Item { + anchors.left: parent.left + anchors.right: root.indicator.left + anchors.leftMargin: root.horizontalPadding + anchors.rightMargin: Tokens.spacing.medium + + implicitWidth: column.implicitWidth + implicitHeight: column.implicitHeight + + Column { + id: column + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + StyledText { + id: label + + anchors.left: parent.left + anchors.right: parent.right + + text: root.text + font: root.font + elide: Text.ElideRight + } + + StyledText { + anchors.left: parent.left + anchors.right: parent.right + + visible: root.subtext + text: root.subtext + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + } +} diff --git a/modules/nexus/common/WallItem.qml b/modules/nexus/common/WallItem.qml new file mode 100644 index 00000000..39dd22e3 --- /dev/null +++ b/modules/nexus/common/WallItem.qml @@ -0,0 +1,105 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +Item { + id: root + + property alias source: img.source + property alias text: label.text + property alias radius: imgWrapper.radius + property alias imgHeight: imgWrapper.implicitHeight + property bool fillLabel: true + + signal clicked + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Tokens.spacing.small + + StyledClippingRect { + id: imgWrapper + + Layout.fillWidth: true + implicitHeight: width + radius: Tokens.rounding.largeIncreased + color: Colours.tPalette.m3surfaceContainer + + Loader { + anchors.centerIn: parent + + opacity: img.status === Image.Ready ? 0 : 1 + active: opacity > 0 + + sourceComponent: StyledRect { + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 + + color: Colours.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: loadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: Math.min(imgWrapper.width, imgWrapper.height) * 0.3 + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Image { + id: img + + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } + retainWhileLoading: true + opacity: status === Image.Ready ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + + StyledText { + id: label + + Layout.bottomMargin: Tokens.padding.small + Layout.fillWidth: true + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.label.builders.small.weight(Font.Medium).build() + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + StateLayer { + anchors.bottomMargin: root.fillLabel ? 0 : layout.implicitHeight - imgWrapper.implicitHeight + onClicked: root.clicked() + } +} diff --git a/modules/nexus/navpane/NavLocations.qml b/modules/nexus/navpane/NavLocations.qml new file mode 100644 index 00000000..d559c443 --- /dev/null +++ b/modules/nexus/navpane/NavLocations.qml @@ -0,0 +1,130 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.containers +import qs.services +import qs.modules.nexus + +VerticalFadeFlickable { + id: root + + required property NexusState nState + + topMargin: Tokens.padding.large + bottomMargin: Tokens.padding.large + contentHeight: content.implicitHeight + + ColumnLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.extraSmall + + Repeater { + id: list + + model: PageRegistry.pages + + StyledRect { + id: item + + required property var modelData + required property int index + + readonly property bool isCurrentPage: index === root.nState.currentPageIdx + readonly property bool isCategoryStart: index === 0 || PageRegistry.pages[index - 1].category !== modelData.category + readonly property bool isCategoryEnd: index === list.model.length - 1 || PageRegistry.pages[index + 1].category !== modelData.category + + Layout.fillWidth: true + Layout.topMargin: index !== 0 && isCategoryStart ? Tokens.spacing.medium : 0 + implicitHeight: { + const h = layout.implicitHeight + layout.anchors.margins * 2; + return h % 2 === 0 ? h : h + 1; + } + + color: isCurrentPage ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + + topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLargeIncreased : isCategoryStart ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall + topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLargeIncreased : isCategoryStart ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall + bottomLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLargeIncreased : isCategoryEnd ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall + bottomRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLargeIncreased : isCategoryEnd ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall + + RadiusBehavior on topLeftRadius {} + RadiusBehavior on topRightRadius {} + RadiusBehavior on bottomLeftRadius {} + RadiusBehavior on bottomRightRadius {} + + StateLayer { + id: stateLayer + + anchors.fill: parent + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + bottomLeftRadius: parent.bottomLeftRadius + bottomRightRadius: parent.bottomRightRadius + + onClicked: root.nState.currentPageIdx = item.index + } + + RowLayout { + id: layout + + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.medium + + StyledRect { + Layout.fillHeight: true + Layout.topMargin: -1 + Layout.bottomMargin: -1 + implicitWidth: height + + radius: Tokens.rounding.full + color: item.isCurrentPage ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer + + MaterialIcon { + anchors.centerIn: parent + anchors.verticalCenterOffset: 1 + + text: item.modelData.icon + color: item.isCurrentPage ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer + fontStyle: Tokens.font.icon.builders.medium.weight(Font.Medium).build() + grade: 25 + fill: item.modelData.noFill ? 0 : 1 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: item.modelData.label + font: Tokens.font.body.medium + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: item.modelData.description + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + } + } + } + } + + component RadiusBehavior: Behavior { + Anim { + type: Anim.DefaultEffects + } + } +} diff --git a/modules/nexus/navpane/SearchBar.qml b/modules/nexus/navpane/SearchBar.qml new file mode 100644 index 00000000..6f35061d --- /dev/null +++ b/modules/nexus/navpane/SearchBar.qml @@ -0,0 +1,81 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus + +StyledRect { + id: root + + required property NexusState nState + + implicitHeight: searchLayout.implicitHeight + Tokens.padding.medium * 2 + + radius: Tokens.rounding.full + color: Colours.tPalette.m3surfaceContainerLowest + border.color: Colours.palette.m3outlineVariant + + Behavior on border.color { + CAnim {} + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.IBeamCursor + onClicked: searchField.focus = true + } + + RowLayout { + id: searchLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.large + + spacing: Tokens.spacing.small + + MaterialIcon { + text: "search" + color: Colours.palette.m3onSurfaceVariant + fontStyle: Tokens.font.icon.medium + } + + StyledTextField { + id: searchField + + Layout.fillWidth: true + Layout.fillHeight: true + + placeholderText: qsTr("Search settings") + placeholderTextColor: Colours.palette.m3onSurfaceVariant + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.large + + Binding { + target: root.nState + property: "searchOpen" + value: searchField.text.length > 0 + } + } + + IconButton { + icon: "close" + font: Tokens.font.icon.medium + type: IconButton.Text + padding: Tokens.padding.extraSmall + isRound: true + onClicked: searchField.clear() + + opacity: searchField.text.length > 0 ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + } +} diff --git a/modules/nexus/pages/AboutPage.qml b/modules/nexus/pages/AboutPage.qml new file mode 100644 index 00000000..c3c7b5d5 --- /dev/null +++ b/modules/nexus/pages/AboutPage.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import Caelestia +import Caelestia.Config +import qs.components +import qs.services +import qs.utils +import qs.modules.nexus.common + +PageBase { + id: root + + // Plugin support is not wired up yet; always 0 for now + readonly property int pluginCount: 0 + + property string quickshellVersion + property string cliVersion + + title: qsTr("About") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // e.g. "Quickshell 0.3.0 (revision ...)" + Process { + running: true + command: ["quickshell", "--version"] + stdout: StdioCollector { + onStreamFinished: root.quickshellVersion = text.trim().split(" ")[1] ?? "" + } + } + + // Parsed from the caelestia CLI's package listing; the sh wrapper avoids a + // warning when the (optional) CLI isn't installed + Process { + running: true + command: ["sh", "-c", "caelestia --version 2>/dev/null"] + stdout: StdioCollector { + onStreamFinished: { + const m = text.match(/caelestia-cli\S*\s+(\d+(?:\.\d+)*)/); + root.cliVersion = m ? m[1] : ""; + } + } + } + + // Hero + ConnectedRect { + Layout.fillWidth: true + first: true + last: true + implicitHeight: hero.implicitHeight + Tokens.padding.extraLarge * 2 + + ColumnLayout { + id: hero + + anchors.centerIn: parent + width: parent.width - Tokens.padding.largeIncreased * 2 + spacing: Tokens.spacing.small + + AnimatedLogo { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Tokens.spacing.small + text: "Caelestia" + font: Tokens.font.headline.builders.large.width(110).build() + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: CUtils.version ? `v${CUtils.version}` : "…" + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.medium + } + } + } + + // System + SectionHeader { + text: qsTr("System") + } + + InfoRow { + first: true + label: qsTr("Hostname") + value: SysInfo.hostname + } + + InfoRow { + label: qsTr("Device") + value: SysInfo.device + } + + InfoRow { + label: qsTr("Distro") + value: SysInfo.osPrettyName || SysInfo.osName + } + + InfoRow { + label: qsTr("Kernel") + value: SysInfo.kernel + } + + InfoRow { + last: true + label: qsTr("Firmware") + value: SysInfo.firmware + } + + // Software + SectionHeader { + text: qsTr("Software") + } + + InfoRow { + first: true + label: qsTr("Shell") + value: CUtils.version || "…" + } + + InfoRow { + label: qsTr("CLI") + value: root.cliVersion || "…" + } + + InfoRow { + label: qsTr("Quickshell") + value: root.quickshellVersion || "…" + } + + InfoRow { + last: true + label: qsTr("Qt") + value: CUtils.qtVersion || "…" + } + + // Plugins + SectionHeader { + text: qsTr("Plugins") + } + + InfoRow { + first: true + last: true + label: qsTr("Loaded plugins") + value: root.pluginCount.toString() + } + } +} diff --git a/modules/nexus/pages/AudioPage.qml b/modules/nexus/pages/AudioPage.qml new file mode 100644 index 00000000..8ae3f9cc --- /dev/null +++ b/modules/nexus/pages/AudioPage.qml @@ -0,0 +1,134 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.utils +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Audio") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // Output + SliderRow { + Layout.fillWidth: true + first: true + icon: Icons.getVolumeIcon(Audio.volume, Audio.muted) + label: qsTr("Output") + valueLabel: Math.round(value * 100) + "%" + value: Audio.volume + enabled: !Audio.muted + onMoved: v => Audio.setVolume(v) + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Muted") + checked: Audio.muted + onToggled: Audio.setStreamMuted(Audio.sink, checked) + } + + AudioDeviceList { + nodes: Audio.sinks + currentId: Audio.sink?.id ?? -1 + iconName: "speaker" + placeholderIcon: "speaker" + placeholderText: qsTr("No output devices") + onSelected: node => Audio.setAudioSink(node) + } + + // Input + SliderRow { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.large - parent.spacing + first: true + icon: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted) + label: qsTr("Input") + valueLabel: Math.round(value * 100) + "%" + value: Audio.sourceVolume + enabled: !Audio.sourceMuted + onMoved: v => Audio.setSourceVolume(v) + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Muted") + checked: Audio.sourceMuted + onToggled: Audio.setStreamMuted(Audio.source, checked) + } + + AudioDeviceList { + nodes: Audio.sources + currentId: Audio.source?.id ?? -1 + iconName: "mic" + placeholderIcon: "mic_off" + placeholderText: qsTr("No input devices") + onSelected: node => Audio.setAudioSource(node) + } + + // Per-app volumes + ConnectedRect { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.large - parent.spacing + implicitHeight: appLayout.implicitHeight + appLayout.anchors.margins * 2 + first: true + last: true + + StateLayer { + onClicked: root.nState.openSubPage(1) + } + + RowLayout { + id: appLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + MaterialIcon { + text: "tune" + font: Tokens.font.icon.medium + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: qsTr("App volumes") + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: Audio.streams.length === 0 ? qsTr("No apps playing audio") : Audio.streams.length === 1 ? qsTr("1 app playing audio") : qsTr("%1 apps playing audio").arg(Audio.streams.length) + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + animate: true + } + } + + MaterialIcon { + text: "chevron_right" + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + } + } + } + } +} diff --git a/modules/nexus/pages/BluetoothPage.qml b/modules/nexus/pages/BluetoothPage.qml new file mode 100644 index 00000000..6fac102d --- /dev/null +++ b/modules/nexus/pages/BluetoothPage.qml @@ -0,0 +1,248 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.utils +import qs.modules.nexus.common + +PageBase { + id: root + + readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter // qmllint disable unresolved-type + readonly property bool btEnabled: adapter?.enabled ?? false + + title: qsTr("Connected devices") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Bluetooth") + font: Tokens.font.body.medium + horizontalPadding: Tokens.padding.largeIncreased + checked: root.btEnabled + onToggled: { + if (root.adapter) + root.adapter.enabled = checked; + } + } + + ItemList { + id: savedList + + showList: root.btEnabled + placeholderIcon: root.btEnabled ? "devices_other" : "bluetooth_disabled" + placeholderText: root.btEnabled ? qsTr("No saved devices") : qsTr("Bluetooth disabled") + + model: ScriptModel { + values: Bluetooth.devices.values.filter(d => d.bonded).sort((a, b) => (b.connected - a.connected) || a.name.localeCompare(b.name)) // qmllint disable unresolved-type + } + + delegate: StyledRect { + id: device + + required property BluetoothDevice modelData + readonly property bool connected: modelData && modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + readonly property bool loading: modelData && (modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting) // qmllint disable unresolved-type + property real textOpacity: loading ? 0.5 : 1 + + anchors.left: savedList.list.contentItem.left + anchors.right: savedList.list.contentItem.right + implicitHeight: deviceLayout.implicitHeight + deviceLayout.anchors.margins * 2 + radius: Tokens.rounding.extraSmall + color: "transparent" + + Behavior on textOpacity { + Anim { + type: Anim.DefaultEffects + } + } + + StateLayer { + disabled: device.loading + onClicked: { + if (!device.modelData || device.loading) + return; + device.modelData.connected = !device.connected; + } + } + + RowLayout { + id: deviceLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: deviceIcon.implicitHeight + Tokens.padding.small * 2 + radius: Tokens.rounding.full + color: device.connected ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer + + MaterialIcon { + id: deviceIcon + + anchors.centerIn: parent + text: Icons.getBluetoothIcon(device.modelData?.icon ?? "") + color: device.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer + font: Tokens.font.icon.medium + fill: device.connected ? 1 : 0 + opacity: device.textOpacity + + Behavior on fill { + Anim {} + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + opacity: device.textOpacity + + StyledText { + Layout.fillWidth: true + text: device.modelData?.name ?? qsTr("Unknown") + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: device.connected ? qsTr("Connected%1").arg(device.modelData?.batteryAvailable ? " • " + Math.round(device.modelData.battery * 100) + "%" : "") : qsTr("Saved") + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + animate: true + } + } + + Item { + Layout.fillHeight: true + implicitWidth: height + + AnimLoader { + anchors.centerIn: parent + sourceComp: device.loading ? loadingComp : btnComp + + Component { + id: btnComp + + IconButton { + icon: "settings" + type: IconButton.Text + padding: Tokens.padding.small + inactiveOnColour: device.connected ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + label.fill: 0 + + onClicked: { + root.nState.selectedBtDevice = device.modelData; + root.nState.openSubPage(1); // Per device info page + } + } + } + + Component { + id: loadingComp + + LoadingIndicator { + implicitSize: Math.round(Tokens.font.icon.medium.pointSize * 1.3) + } + } + } + } + } + } + } + + ConnectedRect { + Layout.fillWidth: true + implicitHeight: pairLayout.implicitHeight + pairLayout.anchors.margins * 2 + last: true + + StateLayer { + disabled: !root.btEnabled + onClicked: root.nState.openSubPage(2) + } + + RowLayout { + id: pairLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + opacity: root.btEnabled ? 1 : 0.5 + + Behavior on opacity { + Anim {} + } + + MaterialIcon { + text: "add" + font: Tokens.font.icon.medium + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Pair new device") + font: Tokens.font.body.small + elide: Text.ElideRight + } + } + } + + ToggleRow { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.large - parent.spacing + + first: true + text: qsTr("Discoverable") + subtext: qsTr("Allow nearby devices to find this one") + enabled: root.btEnabled + opacity: root.btEnabled ? 1 : 0.5 + checked: root.adapter?.discoverable ?? false + onToggled: { + if (root.adapter) + root.adapter.discoverable = checked; + } + + Behavior on opacity { + Anim {} + } + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Pairable") + subtext: qsTr("Allow nearby devices to pair with this one") + enabled: root.btEnabled + opacity: root.btEnabled ? 1 : 0.5 + checked: root.adapter?.pairable ?? false + onToggled: { + if (root.adapter) + root.adapter.pairable = checked; + } + + Behavior on opacity { + Anim {} + } + } + } +} diff --git a/modules/nexus/pages/LanguageAndRegion.qml b/modules/nexus/pages/LanguageAndRegion.qml new file mode 100644 index 00000000..b31fc825 --- /dev/null +++ b/modules/nexus/pages/LanguageAndRegion.qml @@ -0,0 +1,175 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.common + +PageBase { + id: root + + // Temperature units (index 0 = Celsius, 1 = Fahrenheit — matches Weather.formatTemp) + readonly property list tempItems: [ + MenuItem { + text: "°C" + }, + MenuItem { + text: "°F" + } + ] + + // Clock format (index 0 = 24-hour, 1 = 12-hour — matches Time.useTwelveHourClock) + readonly property list clockItems: [ + MenuItem { + text: qsTr("24-hour") + }, + MenuItem { + text: qsTr("12-hour") + } + ] + + title: qsTr("Language & region") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // Language + SectionHeader { + first: true + text: qsTr("Language") + } + + // Read-only: the shell follows the system locale (no in-shell translations yet) + ConnectedRect { + Layout.fillWidth: true + first: true + last: true + implicitHeight: localeLayout.implicitHeight + localeLayout.anchors.margins * 2 + + RowLayout { + id: localeLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: qsTr("System language") + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Follows your system locale (%1)").arg(Qt.locale().name) + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + + StyledText { + text: Qt.locale().nativeLanguageName || Qt.locale().name + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + } + } + + // Weather + SectionHeader { + text: qsTr("Weather") + } + + // Placeholder until the map-based location picker lands + ConnectedRect { + Layout.fillWidth: true + first: true + last: true + implicitHeight: comingSoon.implicitHeight + Tokens.padding.extraLarge * 2 + + ColumnLayout { + id: comingSoon + + anchors.centerIn: parent + width: parent.width - Tokens.padding.largeIncreased * 2 + spacing: Tokens.padding.extraSmall + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "map" + color: Colours.palette.m3outlineVariant + font: Tokens.font.icon.extraLarge + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Location picker coming soon") + color: Colours.palette.m3outlineVariant + font: Tokens.font.title.small + } + + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: qsTr("Choose your weather location on a map in a future update") + color: Colours.palette.m3outlineVariant + font: Tokens.font.body.small + } + } + } + + // Units + SectionHeader { + text: qsTr("Units") + } + + SelectRow { + Layout.fillWidth: true + first: true + label: qsTr("Temperature") + subtext: qsTr("Units for weather temperatures") + menuItems: root.tempItems + active: root.tempItems[GlobalConfig.services.useFahrenheit ? 1 : 0] + onSelected: item => GlobalConfig.services.useFahrenheit = root.tempItems.indexOf(item) === 1 + } + + SelectRow { + Layout.fillWidth: true + last: true + label: qsTr("System temperatures") + subtext: qsTr("Units for CPU and GPU temperatures") + menuItems: root.tempItems + active: root.tempItems[GlobalConfig.services.useFahrenheitPerformance ? 1 : 0] + onSelected: item => GlobalConfig.services.useFahrenheitPerformance = root.tempItems.indexOf(item) === 1 + } + + // Time & date + SectionHeader { + text: qsTr("Time & date") + } + + SelectRow { + Layout.fillWidth: true + first: true + last: true + label: qsTr("Clock format") + subtext: qsTr("How times are shown across the shell") + menuItems: root.clockItems + active: root.clockItems[GlobalConfig.services.useTwelveHourClock ? 1 : 0] + onSelected: item => GlobalConfig.services.useTwelveHourClock = root.clockItems.indexOf(item) === 1 + } + } +} diff --git a/modules/nexus/pages/NetworkPage.qml b/modules/nexus/pages/NetworkPage.qml new file mode 100644 index 00000000..b595a64d --- /dev/null +++ b/modules/nexus/pages/NetworkPage.qml @@ -0,0 +1,235 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.utils +import qs.modules.nexus.common + +PageBase { + id: root + + signal networkSelected(ap: Nmcli.AccessPoint) + + title: qsTr("Network") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + Timer { + running: root.visible && Nmcli.wifiEnabled + repeat: true + triggeredOnStart: true + interval: GlobalConfig.nexus.networkRescanInterval + onTriggered: Nmcli.rescanWifi() + } + + Timer { + id: wifiScanDelay + + interval: 100 + onTriggered: Nmcli.rescanWifi() + } + + Connections { + function onWifiEnabledChanged(): void { + if (Nmcli.wifiEnabled) + wifiScanDelay.start(); + } + + target: Nmcli + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Wi-Fi") + font: Tokens.font.body.medium + horizontalPadding: Tokens.padding.largeIncreased + checked: Nmcli.wifiEnabled + onToggled: Nmcli.enableWifi(checked) + } + + ItemList { + id: networkList + + showList: Nmcli.wifiEnabled + placeholderIcon: Nmcli.wifiEnabled ? "wifi_find" : "signal_wifi_off" + placeholderText: Nmcli.wifiEnabled ? qsTr("No networks found") : qsTr("Wi-Fi disabled") + extraHeight: Nmcli.scanning ? Tokens.rounding.extraSmall : 0 // Inline so it isn't affected by anim + list.anchors.top: scanningIndicator.bottom + + model: ScriptModel { + values: { + const connecting = Nmcli.connectingSsid(); + // Lower rank sorts higher in the list + const rank = n => n.active ? 0 : n.ssid === connecting ? 1 : Nmcli.hasSavedProfile(n.ssid) ? 2 : 3; + return [...Nmcli.networks].sort((a, b) => rank(a) - rank(b) || b.strength - a.strength); + } + } + + delegate: StateLayer { + id: network + + required property Nmcli.AccessPoint modelData + property bool currentSelected + property real textOpacity: disabled ? 0.5 : 1 + + disabled: currentSelected || Nmcli.connectingSsid() === modelData.ssid + + anchors.left: networkList.list.contentItem.left + anchors.right: networkList.list.contentItem.right + implicitHeight: networkLayout.implicitHeight + networkLayout.anchors.margins * 2 + radius: Tokens.rounding.extraSmall + anchors.fill: undefined + + onClicked: { + if (!modelData.active) { + NetworkConnection.handleConnect(modelData); + currentSelected = true; + root.networkSelected(modelData); + } + } + + Behavior on textOpacity { + Anim { + type: Anim.DefaultEffects + } + } + + Connections { + function onActiveChanged(): void { + if (network.modelData.active) + network.currentSelected = false; + } + + target: network.modelData + } + + Connections { + function onNetworkSelected(ap: Nmcli.AccessPoint): void { + if (ap !== network.modelData) + network.currentSelected = false; + } + + target: root + } + + RowLayout { + id: networkLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.large + anchors.leftMargin: Tokens.padding.extraLarge + anchors.rightMargin: Tokens.padding.extraLarge + spacing: Tokens.spacing.medium + + MaterialIcon { + text: Icons.getNetworkIcon(network.modelData.strength) + color: network.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + opacity: network.textOpacity + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + opacity: network.textOpacity + + StyledText { + Layout.fillWidth: true + text: network.modelData.ssid + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Security: %1%2").arg(network.modelData.security).arg(Nmcli.hasSavedProfile(network.modelData.ssid) ? qsTr(" • Saved") : "") + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + + AnimLoader { + sourceComp: Nmcli.connectingSsid() === network.modelData.ssid ? loadingComp : iconComp + + Component { + id: iconComp + + MaterialIcon { + text: network.modelData.active ? "settings" : "lock" + color: network.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + opacity: network.textOpacity + } + } + + Component { + id: loadingComp + + LoadingIndicator { + implicitSize: Math.round(Tokens.font.icon.medium.pointSize * 1.3) + } + } + } + } + } + + StyledProgressBar { + id: scanningIndicator + + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 1 + implicitHeight: Nmcli.scanning ? Tokens.rounding.extraSmall : 0 + indeterminate: true + + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects + } + } + } + } + + ConnectedRect { + Layout.fillWidth: true + implicitHeight: addNetworkLayout.implicitHeight + addNetworkLayout.anchors.margins * 2 + last: true + + StateLayer {} + + RowLayout { + id: addNetworkLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + + spacing: Tokens.spacing.medium + + MaterialIcon { + text: "add" + font: Tokens.font.icon.medium + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Add network") + font: Tokens.font.body.small + elide: Text.ElideRight + } + } + } + } +} diff --git a/modules/nexus/pages/PanelsPage.qml b/modules/nexus/pages/PanelsPage.qml new file mode 100644 index 00000000..d49879be --- /dev/null +++ b/modules/nexus/pages/PanelsPage.qml @@ -0,0 +1,46 @@ +import QtQuick.Layouts +import Caelestia.Config +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Panels") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + NavRow { + first: true + icon: "dashboard" + label: qsTr("Dashboard") + status: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") + onClicked: root.nState.openSubPage(1) + } + + NavRow { + icon: "dock_to_bottom" + label: qsTr("Taskbar") + status: Config.bar.persistent ? qsTr("Always visible") : Config.bar.showOnHover ? qsTr("Reveal on hover") : qsTr("Reveal on drag") + onClicked: root.nState.openSubPage(2) + } + + NavRow { + icon: "apps" + label: qsTr("Launcher") + status: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") + onClicked: root.nState.openSubPage(3) + } + + NavRow { + last: true + icon: "dock_to_right" + label: qsTr("Sidebar") + status: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") + onClicked: root.nState.openSubPage(4) + } + } +} diff --git a/modules/nexus/pages/ServicesPage.qml b/modules/nexus/pages/ServicesPage.qml new file mode 100644 index 00000000..16c92d35 --- /dev/null +++ b/modules/nexus/pages/ServicesPage.qml @@ -0,0 +1,225 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Config +import Caelestia.Services +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.common + +PageBase { + id: root + + // Lyrics backends, ordered to match LyricsBackend::Backend (Auto, Local, LRCLIB, NetEase) + readonly property list lyricsItems: [ + MenuItem { + text: qsTr("Auto") + }, + MenuItem { + text: "Local" + }, + MenuItem { + text: "LRCLIB" + }, + MenuItem { + text: "NetEase" + } + ] + + // GPU options + the config string each maps to (see Gpu::parseType) + readonly property list gpuItems: [ + MenuItem { + text: qsTr("Auto") + }, + MenuItem { + text: "NVIDIA" + }, + MenuItem { + text: qsTr("Generic") + }, + MenuItem { + text: qsTr("None") + } + ] + readonly property list gpuValues: ["", "NVIDIA", "GENERIC", "None"] + + function gpuKeyToIndex(key: string): int { + const u = (key ?? "").trim().toUpperCase(); + if (u === "") + return 0; // Auto + if (u === "NVIDIA") + return 1; + if (u === "GENERIC") + return 2; + return 3; // None + } + + title: qsTr("Services") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // Detected running players, used as default-player options + Variants { + id: playerVariants + + model: [...new Set(Players.list.map(p => Players.getIdentity(p)).filter(id => id))] + + MenuItem { + required property string modelData + + text: modelData + icon: modelData === GlobalConfig.services.defaultPlayer ? "check" : "" + activeIcon: "music_note" + } + } + + // Polling + SectionHeader { + first: true + text: qsTr("Polling") + } + + StepperRow { + Layout.fillWidth: true + first: true + label: qsTr("Media refresh") + subtext: qsTr("How often the media position updates (ms)") + value: GlobalConfig.dashboard.mediaUpdateInterval + from: 100 + to: 2000 + stepSize: 50 + onMoved: v => GlobalConfig.dashboard.mediaUpdateInterval = v + } + + StepperRow { + Layout.fillWidth: true + label: qsTr("System stats refresh") + subtext: qsTr("CPU, memory and GPU update interval (seconds)") + value: GlobalConfig.dashboard.resourceUpdateInterval / 1000 + from: 0.5 + to: 10 + stepSize: 0.5 + onMoved: v => GlobalConfig.dashboard.resourceUpdateInterval = Math.round(v * 1000) + } + + StepperRow { + Layout.fillWidth: true + last: true + label: qsTr("Wi-Fi rescan") + subtext: qsTr("How often available networks are rescanned (seconds)") + value: GlobalConfig.nexus.networkRescanInterval / 1000 + from: 5 + to: 120 + stepSize: 5 + onMoved: v => GlobalConfig.nexus.networkRescanInterval = Math.round(v * 1000) + } + + // Media & lyrics + SectionHeader { + text: qsTr("Media & lyrics") + } + + SelectRow { + Layout.fillWidth: true + first: true + label: qsTr("Lyrics backend") + subtext: qsTr("Source used to fetch synced lyrics") + menuItems: root.lyricsItems + active: root.lyricsItems[Lyrics.preferredBackend] ?? root.lyricsItems[0] + onSelected: item => Lyrics.preferredBackend = root.lyricsItems.indexOf(item) + } + + SelectRow { + Layout.fillWidth: true + last: true + label: qsTr("Default player") + subtext: qsTr("Preferred media player when several are open") + menuItems: playerVariants.instances + active: menuItems.find(i => i.text === GlobalConfig.services.defaultPlayer) ?? null + fallbackIcon: "music_note" + fallbackText: GlobalConfig.services.defaultPlayer || qsTr("Auto") + onSelected: item => GlobalConfig.services.defaultPlayer = item.text + } + + // Input increments + SectionHeader { + text: qsTr("Input increments") + } + + StepperRow { + Layout.fillWidth: true + first: true + label: qsTr("Volume step") + subtext: qsTr("Amount the volume changes per scroll (%)") + value: Math.round(GlobalConfig.services.audioIncrement * 100) + from: 1 + to: 50 + stepSize: 1 + onMoved: v => GlobalConfig.services.audioIncrement = v / 100 + } + + StepperRow { + Layout.fillWidth: true + label: qsTr("Brightness step") + subtext: qsTr("Amount the brightness changes per scroll (%)") + value: Math.round(GlobalConfig.services.brightnessIncrement * 100) + from: 1 + to: 50 + stepSize: 1 + onMoved: v => GlobalConfig.services.brightnessIncrement = v / 100 + } + + StepperRow { + Layout.fillWidth: true + last: true + label: qsTr("Max volume") + subtext: qsTr("Upper limit for output volume (%)") + value: Math.round(GlobalConfig.services.maxVolume * 100) + from: 50 + to: 200 + stepSize: 5 + onMoved: v => GlobalConfig.services.maxVolume = v / 100 + } + + // Service tuning + SectionHeader { + text: qsTr("Service tuning") + } + + StepperRow { + Layout.fillWidth: true + first: true + label: qsTr("Visualiser bars") + subtext: qsTr("Number of bars in the audio visualisers") + value: GlobalConfig.services.visualiserBars + from: 10 + to: 120 + stepSize: 2 + onMoved: v => GlobalConfig.services.visualiserBars = v + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Smart colour scheme") + subtext: qsTr("Derive theme mode and variant from the wallpaper") + checked: GlobalConfig.services.smartScheme + onToggled: GlobalConfig.services.smartScheme = checked + } + + SelectRow { + Layout.fillWidth: true + last: true + label: qsTr("GPU") + subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type") + menuOnTop: true + menuItems: root.gpuItems + active: root.gpuItems[root.gpuKeyToIndex(GlobalConfig.services.gpuType)] + onSelected: item => GlobalConfig.services.gpuType = root.gpuValues[root.gpuItems.indexOf(item)] + } + } +} diff --git a/modules/nexus/pages/WallpaperAndStyle.qml b/modules/nexus/pages/WallpaperAndStyle.qml new file mode 100644 index 00000000..98a5e413 --- /dev/null +++ b/modules/nexus/pages/WallpaperAndStyle.qml @@ -0,0 +1,202 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Components +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.components.images +import qs.services +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Wallpaper & style") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.large + + StyledClippingRect { + id: wallWrapper + + Layout.alignment: Qt.AlignHCenter + implicitWidth: { + const screen = root.nState.screen; + return implicitHeight / screen.height * screen.width; + } + implicitHeight: { + const screen = root.nState.screen; + const cWidth = root.cappedWidth; + return Math.min(Math.round(cWidth * 0.4), cWidth / screen.width * screen.height); + } + + color: Colours.tPalette.m3surfaceContainer + radius: Tokens.rounding.large + + Loader { + anchors.centerIn: parent + opacity: Config.background.wallpaperEnabled ? 0 : 1 + active: opacity > 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.extraSmall + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "hide_image" + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.extraLarge + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Wallpaper disabled") + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.large + } + } + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + + Item { + anchors.fill: parent + opacity: Config.background.wallpaperEnabled ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + Loader { + id: wallIndicatorLoader + + anchors.centerIn: parent + + opacity: 0 + active: opacity > 0 + + sourceComponent: StyledRect { + implicitWidth: wallLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + implicitHeight: wallLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + + color: Colours.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: wallLoadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: Math.min(wallWrapper.implicitWidth, wallWrapper.implicitHeight) * 0.4 + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Timer { + id: wallLoadDebounceTimer + + interval: 100 + onTriggered: { + if (wallImg.status !== Image.Ready) + wallIndicatorLoader.opacity = 1; + } + } + + FadeImage { + id: wallImg + + anchors.fill: parent + source: Wallpapers.current + preventInit: wallIndicatorLoader.opacity > 0 + fadeOutAnim: Anim.DefaultEffects + fadeInAnim: Anim.SlowEffects + + onSourceChanged: wallLoadDebounceTimer.restart() + + onStatusChanged: { + if (status === Image.Ready) { + wallLoadDebounceTimer.stop(); + wallIndicatorLoader.opacity = 0; + } + } + } + } + } + + ButtonRow { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + IconTextButton { + icon: "wallpaper" + text: qsTr("Wallpapers") + font: Tokens.font.body.large + isRound: true + shapeMorph: true + type: IconTextButton.Tonal + horizontalPadding: Tokens.padding.extraLarge + verticalPadding: Tokens.padding.medium + disabled: !Config.background.wallpaperEnabled + onClicked: root.nState.openSubPage(1) // Wallpaper page + } + + IconTextButton { + icon: "palette" + text: qsTr("Colours") + font: Tokens.font.body.large + isRound: true + shapeMorph: true + type: IconTextButton.Tonal + horizontalPadding: Tokens.padding.extraLarge + verticalPadding: Tokens.padding.medium + onClicked: root.nState.openSubPage(3) // Colours page + } + } + + ToggleRow { + Layout.fillWidth: true + + first: true + text: qsTr("Display wallpaper") + checked: Config.background.wallpaperEnabled + onToggled: GlobalConfig.background.wallpaperEnabled = checked + } + + ToggleRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + Layout.fillWidth: true + + text: qsTr("Transparency") + subtext: qsTr("Base %1, layers %2").arg(Colours.transparency.base).arg(Colours.transparency.layers) + checked: Colours.transparency.enabled + onToggled: GlobalConfig.appearance.transparency.enabled = checked + } + + ToggleRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + Layout.fillWidth: true + + last: true + text: qsTr("Dark theme") + checked: !Colours.light + onToggled: Colours.setMode(checked ? "dark" : "light") + } + } +} diff --git a/modules/nexus/pages/audio/AppVolumes.qml b/modules/nexus/pages/audio/AppVolumes.qml new file mode 100644 index 00000000..8f36a4ca --- /dev/null +++ b/modules/nexus/pages/audio/AppVolumes.qml @@ -0,0 +1,70 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import Caelestia.Config +import qs.components +import qs.services +import qs.utils +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("App volumes") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + StyledText { + Layout.fillWidth: true + Layout.leftMargin: Tokens.padding.small + Layout.bottomMargin: Tokens.spacing.medium + text: qsTr("Adjust the volume of individual apps currently playing audio.") + color: Colours.palette.m3outline + font: Tokens.font.body.small + wrapMode: Text.WordWrap + } + + ItemList { + id: streamList + + first: true + last: true + showList: true + placeholderIcon: "music_off" + placeholderText: qsTr("No apps playing audio") + color: list.count === 0 ? Colours.tPalette.m3surfaceContainer : "transparent" + list.spacing: Tokens.spacing.extraSmall / 2 + + model: ScriptModel { + values: [...Audio.streams] + } + + delegate: SliderRow { + id: stream + + required property PwNode modelData + required property int index + + anchors.left: streamList.list.contentItem.left + anchors.right: streamList.list.contentItem.right + first: index === 0 + last: index === streamList.list.count - 1 + + icon: Icons.getVolumeIcon(stream.modelData?.audio?.volume ?? 0, stream.modelData?.audio?.muted ?? false) + label: Audio.getStreamName(stream.modelData) + valueLabel: Math.round(value * 100) + "%" + value: stream.modelData?.audio?.volume ?? 0 + enabled: !stream.modelData?.audio?.muted + onMoved: v => Audio.setStreamVolume(stream.modelData, v) + } + } + } +} diff --git a/modules/nexus/pages/bluetooth/BluetoothPairing.qml b/modules/nexus/pages/bluetooth/BluetoothPairing.qml new file mode 100644 index 00000000..548bff1a --- /dev/null +++ b/modules/nexus/pages/bluetooth/BluetoothPairing.qml @@ -0,0 +1,193 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.utils +import qs.modules.nexus.common + +PageBase { + id: root + + readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter // qmllint disable unresolved-type + + function setScan(on: bool): void { + if (adapter?.enabled) + adapter.discovering = on; + } + + title: qsTr("Pair new device") + isSubPage: true + + Component.onCompleted: setScan(true) + Component.onDestruction: setScan(false) + onVisibleChanged: setScan(visible) + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + Connections { + function onEnabledChanged(): void { + if (root.adapter && !root.adapter.enabled) + root.nState.closeSubPage(); + } + + target: root.adapter + } + + ConnectedRect { + Layout.fillWidth: true + implicitHeight: headerText.implicitHeight + Tokens.padding.medium * 2 + first: true + + StyledText { + id: headerText + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Tokens.padding.large + anchors.verticalCenterOffset: Math.round(fontInfo.pointSize * 0.2) + + text: qsTr("Available devices") + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + } + + ItemList { + id: deviceList + + Layout.fillWidth: true + showList: true + extraHeight: scanIndicator.implicitHeight + last: true + placeholderIcon: "bluetooth_searching" + placeholderText: qsTr("Searching for devices…") + list.anchors.top: scanIndicator.bottom + + model: ScriptModel { + values: Bluetooth.devices.values.filter(d => !d.bonded).sort((a, b) => (b.pairing - a.pairing) || a.name.localeCompare(b.name)) // qmllint disable unresolved-type + } + + delegate: Item { + id: newDevice + + required property BluetoothDevice modelData + required property int index + property real textOpacity: modelData?.pairing ? 0.5 : 1 + property bool wasPairing + + anchors.left: deviceList.list.contentItem.left + anchors.right: deviceList.list.contentItem.right + implicitHeight: newLayout.implicitHeight + newLayout.anchors.margins * 2 + + Behavior on textOpacity { + Anim { + type: Anim.DefaultEffects + } + } + + Connections { + function onPairedChanged(): void { + if (newDevice.wasPairing && newDevice.modelData?.paired) + root.nState.closeSubPage(); + } + + target: newDevice.modelData + } + + StateLayer { + radius: Tokens.rounding.extraSmall + bottomLeftRadius: newDevice.index === deviceList?.list.count - 1 ? Tokens.rounding.extraLarge : radius + bottomRightRadius: newDevice.index === deviceList?.list.count - 1 ? Tokens.rounding.extraLarge : radius + disabled: newDevice.modelData?.pairing ?? false + + onClicked: { + newDevice.modelData?.pair(); + newDevice.wasPairing = true; + } + } + + RowLayout { + id: newLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + MaterialIcon { + text: Icons.getBluetoothIcon(newDevice.modelData?.icon ?? "") + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.icon.medium + opacity: newDevice.textOpacity + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + opacity: newDevice.textOpacity + + StyledText { + Layout.fillWidth: true + text: newDevice.modelData?.name || qsTr("Unknown device") + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: newDevice.modelData?.pairing ? qsTr("Pairing...") : (newDevice.modelData?.address ?? "") + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + animate: true + } + } + + Loader { + asynchronous: true + active: opacity > 0 + opacity: newDevice.modelData?.pairing ? 1 : 0 + + sourceComponent: LoadingIndicator { + implicitSize: Math.round(Tokens.font.icon.medium.pointSize * 1.3) + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + } + } + + StyledProgressBar { + id: scanIndicator + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 1 + implicitHeight: Tokens.rounding.extraSmall + indeterminate: true + + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects + } + } + } + } + } +} diff --git a/modules/nexus/pages/bluetooth/BtDeviceInfo.qml b/modules/nexus/pages/bluetooth/BtDeviceInfo.qml new file mode 100644 index 00000000..604973b6 --- /dev/null +++ b/modules/nexus/pages/bluetooth/BtDeviceInfo.qml @@ -0,0 +1,260 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell.Bluetooth +import Caelestia.Components +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.common + +PageBase { + id: root + + readonly property BluetoothDevice device: nState.selectedBtDevice + readonly property bool connected: device?.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + readonly property bool loading: device?.state === BluetoothDeviceState.Connecting || device?.state === BluetoothDeviceState.Disconnecting // qmllint disable unresolved-type + + readonly property string statusText: { + if (!device) + return ""; + let s = connected ? qsTr("Connected") : (device.bonded ? qsTr("Paired") : qsTr("Not paired")); + if (connected && device.batteryAvailable) + s += " • " + Math.round(device.battery * 100) + "%"; + return s; + } + + onDeviceChanged: { + // Auto close when device lost + if (!device) + nState.closeSubPage(); + } + + title: device?.name ?? qsTr("Device") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // Big buttons + ButtonRow { + Layout.bottomMargin: Tokens.spacing.large - parent.spacing + Layout.alignment: Qt.AlignHCenter + Layout.minimumWidth: Math.round(root.cappedWidth * 0.7) + spacing: Tokens.spacing.small + + ButtonBase { + id: forgetBtn + + fillWidth: true + shapeMorph: true + isRound: true + + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + + implicitWidth: forgetBtnLayout.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: forgetBtnLayout.implicitHeight + Tokens.padding.medium * 2 + + onClicked: { + root.device?.forget(); + root.nState.closeSubPage(); + } + + ColumnLayout { + id: forgetBtnLayout + + anchors.centerIn: parent + spacing: 0 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "delete" + color: forgetBtn.onColour + font: Tokens.font.icon.medium + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Forget") + color: forgetBtn.onColour + } + } + } + + ButtonBase { + id: connectBtn + + fillWidth: true + shapeMorph: true + isRound: true + + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + stateLayer.disabled: root.loading + + implicitWidth: connectBtnContent.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: connectBtnContent.implicitHeight + Tokens.padding.medium * 2 + + onClicked: root.device.connected = !root.connected + + AnimLoader { + id: connectBtnContent + + anchors.centerIn: parent + sourceComp: root.loading ? loadingComp : textComp + outAnimType: Anim.SlowEffects + inAnimType: Anim.SlowEffects + } + + Component { + id: loadingComp + + LoadingIndicator { + implicitSize: connectBtn.height - Tokens.padding.large * 2 + } + } + + Component { + id: textComp + + ColumnLayout { + spacing: 0 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: root.connected ? "close" : "add" + color: connectBtn.inactiveOnColour + font: Tokens.font.icon.medium + animate: true + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.connected ? qsTr("Disconnect") : qsTr("Connect") + color: connectBtn.inactiveOnColour + animate: true + } + } + } + } + } + + // Connection group + ToggleRow { + Layout.fillWidth: true + verticalPadding: Tokens.padding.large + first: true + text: qsTr("Trusted") + subtext: qsTr("Allow this device to connect automatically") + checked: root.device?.trusted ?? false + onToggled: { + if (root.device) + root.device.trusted = checked; + } + } + + ToggleRow { + Layout.fillWidth: true + verticalPadding: Tokens.padding.large + text: qsTr("Blocked") + subtext: qsTr("Prevent this device from connecting") + checked: root.device?.blocked ?? false + onToggled: { + if (root.device) + root.device.blocked = checked; + } + } + + ToggleRow { + Layout.fillWidth: true + verticalPadding: Tokens.padding.large + last: true + text: qsTr("Wake allowed") + subtext: qsTr("Allow this device to wake the system") + checked: root.device?.wakeAllowed ?? false + onToggled: { + if (root.device) + root.device.wakeAllowed = checked; + } + } + + // Information + ConnectedRect { + Layout.topMargin: Tokens.spacing.large - parent.spacing + Layout.fillWidth: true + implicitHeight: batteryLayout.implicitHeight + Tokens.padding.large * 2 + first: true + + ColumnLayout { + id: batteryLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + StyledText { + Layout.fillWidth: true + text: qsTr("Battery") + } + + StyledText { + text: root.device?.batteryAvailable ? Math.round(root.device.battery * 100) + "%" : qsTr("Unavailable") + color: Colours.palette.m3outline + font: Tokens.font.body.small + } + } + + Loader { + Layout.fillWidth: true + active: root.device?.batteryAvailable ?? false + visible: active + asynchronous: true + + sourceComponent: StyledProgressBar { + implicitHeight: Tokens.padding.medium + value: root.device.battery + } + } + } + } + + ConnectedRect { + Layout.fillWidth: true + implicitHeight: addrLayout.implicitHeight + Tokens.padding.large * 2 + last: true + + RowLayout { + id: addrLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.medium + + StyledText { + Layout.fillWidth: true + text: qsTr("Address") + } + + StyledText { + text: root.device?.address ?? "" + color: Colours.palette.m3outline + font: Tokens.font.body.small + } + } + } + } +} diff --git a/modules/nexus/pages/panels/DashboardPanel.qml b/modules/nexus/pages/panels/DashboardPanel.qml new file mode 100644 index 00000000..64d0292e --- /dev/null +++ b/modules/nexus/pages/panels/DashboardPanel.qml @@ -0,0 +1,146 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Dashboard") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // General + SectionHeader { + first: true + text: qsTr("General") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Enabled") + checked: Config.dashboard.enabled + onToggled: GlobalConfig.dashboard.enabled = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Show on hover") + subtext: qsTr("Reveal when the cursor reaches the screen edge") + checked: Config.dashboard.showOnHover + onToggled: GlobalConfig.dashboard.showOnHover = checked + } + + // Tabs + SectionHeader { + text: qsTr("Tabs") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Dashboard") + checked: Config.dashboard.showDashboard + onToggled: GlobalConfig.dashboard.showDashboard = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Media") + checked: Config.dashboard.showMedia + onToggled: GlobalConfig.dashboard.showMedia = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Performance") + checked: Config.dashboard.showPerformance + onToggled: GlobalConfig.dashboard.showPerformance = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Weather") + checked: Config.dashboard.showWeather + onToggled: GlobalConfig.dashboard.showWeather = checked + } + + // Performance widgets + SectionHeader { + text: qsTr("Performance widgets") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Battery") + checked: Config.dashboard.performance.showBattery + onToggled: GlobalConfig.dashboard.performance.showBattery = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("GPU") + checked: Config.dashboard.performance.showGpu + onToggled: GlobalConfig.dashboard.performance.showGpu = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("CPU") + checked: Config.dashboard.performance.showCpu + onToggled: GlobalConfig.dashboard.performance.showCpu = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Memory") + checked: Config.dashboard.performance.showMemory + onToggled: GlobalConfig.dashboard.performance.showMemory = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Storage") + checked: Config.dashboard.performance.showStorage + onToggled: GlobalConfig.dashboard.performance.showStorage = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Network") + checked: Config.dashboard.performance.showNetwork + onToggled: GlobalConfig.dashboard.performance.showNetwork = checked + } + + // Behaviour + SectionHeader { + text: qsTr("Behaviour") + } + + StepperRow { + Layout.fillWidth: true + first: true + last: true + label: qsTr("Drag threshold") + subtext: qsTr("Pixels dragged before the dashboard opens") + value: Config.dashboard.dragThreshold + from: 0 + to: 200 + stepSize: 5 + onMoved: v => GlobalConfig.dashboard.dragThreshold = v + } + } +} diff --git a/modules/nexus/pages/panels/LauncherPanel.qml b/modules/nexus/pages/panels/LauncherPanel.qml new file mode 100644 index 00000000..ffa2bfcb --- /dev/null +++ b/modules/nexus/pages/panels/LauncherPanel.qml @@ -0,0 +1,147 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Launcher") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // General + SectionHeader { + first: true + text: qsTr("General") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Enabled") + checked: Config.launcher.enabled + onToggled: GlobalConfig.launcher.enabled = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Show on hover") + subtext: qsTr("Reveal when the cursor reaches the screen edge") + checked: Config.launcher.showOnHover + onToggled: GlobalConfig.launcher.showOnHover = checked + } + + // Display + SectionHeader { + text: qsTr("Display") + } + + StepperRow { + Layout.fillWidth: true + first: true + label: qsTr("Max items shown") + value: Config.launcher.maxShown + from: 1 + to: 20 + stepSize: 1 + onMoved: v => GlobalConfig.launcher.maxShown = v + } + + StepperRow { + Layout.fillWidth: true + label: qsTr("Max wallpapers") + value: Config.launcher.maxWallpapers + from: 1 + to: 30 + stepSize: 1 + onMoved: v => GlobalConfig.launcher.maxWallpapers = v + } + + StepperRow { + Layout.fillWidth: true + last: true + label: qsTr("Drag threshold") + subtext: qsTr("Pixels dragged before the launcher opens") + value: Config.launcher.dragThreshold + from: 0 + to: 200 + stepSize: 5 + onMoved: v => GlobalConfig.launcher.dragThreshold = v + } + + // Behaviour + SectionHeader { + text: qsTr("Behaviour") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Vim keybinds") + subtext: qsTr("Navigate results with Ctrl+hjkl") + checked: GlobalConfig.launcher.vimKeybinds + onToggled: GlobalConfig.launcher.vimKeybinds = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Enable dangerous actions") + subtext: qsTr("Allow actions that shut down or log out") + checked: GlobalConfig.launcher.enableDangerousActions + onToggled: GlobalConfig.launcher.enableDangerousActions = checked + } + + // Fuzzy search + SectionHeader { + text: qsTr("Fuzzy search") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Apps") + checked: GlobalConfig.launcher.useFuzzy.apps + onToggled: GlobalConfig.launcher.useFuzzy.apps = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Actions") + checked: GlobalConfig.launcher.useFuzzy.actions + onToggled: GlobalConfig.launcher.useFuzzy.actions = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Schemes") + checked: GlobalConfig.launcher.useFuzzy.schemes + onToggled: GlobalConfig.launcher.useFuzzy.schemes = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Variants") + checked: GlobalConfig.launcher.useFuzzy.variants + onToggled: GlobalConfig.launcher.useFuzzy.variants = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Wallpapers") + checked: GlobalConfig.launcher.useFuzzy.wallpapers + onToggled: GlobalConfig.launcher.useFuzzy.wallpapers = checked + } + } +} diff --git a/modules/nexus/pages/panels/SidebarPanel.qml b/modules/nexus/pages/panels/SidebarPanel.qml new file mode 100644 index 00000000..0ceaad69 --- /dev/null +++ b/modules/nexus/pages/panels/SidebarPanel.qml @@ -0,0 +1,46 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Sidebar") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + SectionHeader { + first: true + text: qsTr("General") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Enabled") + checked: Config.sidebar.enabled + onToggled: GlobalConfig.sidebar.enabled = checked + } + + StepperRow { + Layout.fillWidth: true + last: true + label: qsTr("Drag threshold") + subtext: qsTr("Pixels dragged before the sidebar opens") + value: Config.sidebar.dragThreshold + from: 0 + to: 200 + stepSize: 5 + onMoved: v => GlobalConfig.sidebar.dragThreshold = v + } + } +} diff --git a/modules/nexus/pages/panels/TaskbarPanel.qml b/modules/nexus/pages/panels/TaskbarPanel.qml new file mode 100644 index 00000000..5b0bb870 --- /dev/null +++ b/modules/nexus/pages/panels/TaskbarPanel.qml @@ -0,0 +1,127 @@ +pragma ComponentBehavior: Bound + +import QtQuick.Layouts +import Caelestia.Config +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Taskbar") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // Behaviour + SectionHeader { + first: true + text: qsTr("Behaviour") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Persistent") + subtext: qsTr("Keep the bar visible at all times") + checked: Config.bar.persistent + onToggled: GlobalConfig.bar.persistent = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Show on hover") + subtext: qsTr("Reveal the bar when the cursor reaches the screen edge") + checked: Config.bar.showOnHover + onToggled: GlobalConfig.bar.showOnHover = checked + } + + StepperRow { + Layout.fillWidth: true + last: true + label: qsTr("Drag threshold") + subtext: qsTr("Pixels dragged before the bar reveals") + value: Config.bar.dragThreshold + from: 0 + to: 200 + stepSize: 5 + onMoved: v => GlobalConfig.bar.dragThreshold = v + } + + // Components + SectionHeader { + text: qsTr("Components") + } + + NavRow { + first: true + icon: "workspaces" + label: qsTr("Workspaces") + status: qsTr("Indicators, window icons") + onClicked: root.nState.openSubPage(5) + } + + NavRow { + icon: "web_asset" + label: qsTr("Active window") + status: qsTr("Title display, popout") + onClicked: root.nState.openSubPage(6) + } + + NavRow { + icon: "widgets" + label: qsTr("Tray") + status: qsTr("System tray icons") + onClicked: root.nState.openSubPage(7) + } + + NavRow { + icon: "signal_cellular_alt" + label: qsTr("Status icons") + status: qsTr("Visible indicators") + onClicked: root.nState.openSubPage(8) + } + + NavRow { + last: true + icon: "schedule" + label: qsTr("Clock") + status: qsTr("Date, icon, background") + onClicked: root.nState.openSubPage(9) + } + + // Scroll actions + SectionHeader { + text: qsTr("Scroll actions") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Workspaces") + subtext: qsTr("Scroll over the workspace indicator to switch workspaces") + checked: Config.bar.scrollActions.workspaces + onToggled: GlobalConfig.bar.scrollActions.workspaces = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Volume") + subtext: qsTr("Scroll on the top half of the bar to adjust volume") + checked: Config.bar.scrollActions.volume + onToggled: GlobalConfig.bar.scrollActions.volume = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Brightness") + subtext: qsTr("Scroll on the bottom half of the bar to adjust brightness") + checked: Config.bar.scrollActions.brightness + onToggled: GlobalConfig.bar.scrollActions.brightness = checked + } + } +} diff --git a/modules/nexus/pages/panels/taskbar/BarActiveWindow.qml b/modules/nexus/pages/panels/taskbar/BarActiveWindow.qml new file mode 100644 index 00000000..e61685e1 --- /dev/null +++ b/modules/nexus/pages/panels/taskbar/BarActiveWindow.qml @@ -0,0 +1,51 @@ +pragma ComponentBehavior: Bound + +import QtQuick.Layouts +import Caelestia.Config +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Active window") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Compact") + checked: Config.bar.activeWindow.compact + onToggled: GlobalConfig.bar.activeWindow.compact = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Inverted") + checked: Config.bar.activeWindow.inverted + onToggled: GlobalConfig.bar.activeWindow.inverted = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Show on hover") + subtext: qsTr("Only show the active window title while hovering") + checked: Config.bar.activeWindow.showOnHover + onToggled: GlobalConfig.bar.activeWindow.showOnHover = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Popout on hover") + subtext: qsTr("Show a window details popout when hovering") + checked: Config.bar.popouts.activeWindow + onToggled: GlobalConfig.bar.popouts.activeWindow = checked + } + } +} diff --git a/modules/nexus/pages/panels/taskbar/BarClock.qml b/modules/nexus/pages/panels/taskbar/BarClock.qml new file mode 100644 index 00000000..b4291b00 --- /dev/null +++ b/modules/nexus/pages/panels/taskbar/BarClock.qml @@ -0,0 +1,42 @@ +pragma ComponentBehavior: Bound + +import QtQuick.Layouts +import Caelestia.Config +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Clock") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Background") + checked: Config.bar.clock.background + onToggled: GlobalConfig.bar.clock.background = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Show date") + checked: Config.bar.clock.showDate + onToggled: GlobalConfig.bar.clock.showDate = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Show icon") + checked: Config.bar.clock.showIcon + onToggled: GlobalConfig.bar.clock.showIcon = checked + } + } +} diff --git a/modules/nexus/pages/panels/taskbar/BarStatusIcons.qml b/modules/nexus/pages/panels/taskbar/BarStatusIcons.qml new file mode 100644 index 00000000..0b9c2631 --- /dev/null +++ b/modules/nexus/pages/panels/taskbar/BarStatusIcons.qml @@ -0,0 +1,98 @@ +pragma ComponentBehavior: Bound + +import QtQuick.Layouts +import Caelestia.Config +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Status icons") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // Visible icons + SectionHeader { + first: true + text: qsTr("Visible icons") + } + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Speakers") + checked: Config.bar.status.showAudio + onToggled: GlobalConfig.bar.status.showAudio = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Microphone") + checked: Config.bar.status.showMicrophone + onToggled: GlobalConfig.bar.status.showMicrophone = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Keyboard layout") + checked: Config.bar.status.showKbLayout + onToggled: GlobalConfig.bar.status.showKbLayout = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Network") + checked: Config.bar.status.showNetwork + onToggled: GlobalConfig.bar.status.showNetwork = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Wi-Fi") + checked: Config.bar.status.showWifi + onToggled: GlobalConfig.bar.status.showWifi = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Bluetooth") + checked: Config.bar.status.showBluetooth + onToggled: GlobalConfig.bar.status.showBluetooth = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Battery") + checked: Config.bar.status.showBattery + onToggled: GlobalConfig.bar.status.showBattery = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Caps lock") + checked: Config.bar.status.showLockStatus + onToggled: GlobalConfig.bar.status.showLockStatus = checked + } + + // Behaviour + SectionHeader { + text: qsTr("Behaviour") + } + + ToggleRow { + Layout.fillWidth: true + first: true + last: true + text: qsTr("Popout on hover") + subtext: qsTr("Show a details popout when hovering the status icons") + checked: Config.bar.popouts.statusIcons + onToggled: GlobalConfig.bar.popouts.statusIcons = checked + } + } +} diff --git a/modules/nexus/pages/panels/taskbar/BarTray.qml b/modules/nexus/pages/panels/taskbar/BarTray.qml new file mode 100644 index 00000000..3d00196e --- /dev/null +++ b/modules/nexus/pages/panels/taskbar/BarTray.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import QtQuick.Layouts +import Caelestia.Config +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Tray") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Background") + checked: Config.bar.tray.background + onToggled: GlobalConfig.bar.tray.background = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Recolour icons") + checked: Config.bar.tray.recolour + onToggled: GlobalConfig.bar.tray.recolour = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Compact") + checked: Config.bar.tray.compact + onToggled: GlobalConfig.bar.tray.compact = checked + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Popout on hover") + subtext: qsTr("Show the tray menu popout when hovering") + checked: Config.bar.popouts.tray + onToggled: GlobalConfig.bar.popouts.tray = checked + } + } +} diff --git a/modules/nexus/pages/panels/taskbar/BarWorkspaces.qml b/modules/nexus/pages/panels/taskbar/BarWorkspaces.qml new file mode 100644 index 00000000..d1388f58 --- /dev/null +++ b/modules/nexus/pages/panels/taskbar/BarWorkspaces.qml @@ -0,0 +1,86 @@ +pragma ComponentBehavior: Bound + +import QtQuick.Layouts +import Caelestia.Config +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Workspaces") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + StepperRow { + Layout.fillWidth: true + first: true + label: qsTr("Shown") + subtext: qsTr("Number of workspaces displayed") + value: Config.bar.workspaces.shown + from: 1 + to: 20 + stepSize: 1 + onMoved: v => GlobalConfig.bar.workspaces.shown = v + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Active indicator") + checked: Config.bar.workspaces.activeIndicator + onToggled: GlobalConfig.bar.workspaces.activeIndicator = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Active trail") + checked: Config.bar.workspaces.activeTrail + onToggled: GlobalConfig.bar.workspaces.activeTrail = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Occupied background") + checked: Config.bar.workspaces.occupiedBg + onToggled: GlobalConfig.bar.workspaces.occupiedBg = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Show windows") + subtext: qsTr("Show icons of open windows on each workspace") + checked: Config.bar.workspaces.showWindows + onToggled: GlobalConfig.bar.workspaces.showWindows = checked + } + + ToggleRow { + Layout.fillWidth: true + text: qsTr("Windows on special workspaces") + checked: Config.bar.workspaces.showWindowsOnSpecialWorkspaces + onToggled: GlobalConfig.bar.workspaces.showWindowsOnSpecialWorkspaces = checked + } + + StepperRow { + Layout.fillWidth: true + label: qsTr("Max window icons") + value: Config.bar.workspaces.maxWindowIcons + from: 0 + to: 20 + stepSize: 1 + onMoved: v => GlobalConfig.bar.workspaces.maxWindowIcons = v + } + + ToggleRow { + Layout.fillWidth: true + last: true + text: qsTr("Per-monitor workspaces") + subtext: qsTr("Show each monitor's workspaces independently") + checked: GlobalConfig.bar.workspaces.perMonitorWorkspaces + onToggled: GlobalConfig.bar.workspaces.perMonitorWorkspaces = checked + } + } +} diff --git a/modules/nexus/pages/wallandstyle/ColourSelect.qml b/modules/nexus/pages/wallandstyle/ColourSelect.qml new file mode 100644 index 00000000..e7816d0b --- /dev/null +++ b/modules/nexus/pages/wallandstyle/ColourSelect.qml @@ -0,0 +1,47 @@ +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Colours") + isSubPage: true + + Item { + anchors.horizontalCenter: parent.horizontalCenter + implicitHeight: { + const f = parent.parent as Flickable; + return f.height - f.topMargin - f.bottomMargin; + } + + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.padding.extraSmall + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "handyman" + color: Colours.palette.m3outlineVariant + font: Tokens.font.icon.extraLarge + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Page under construction") + color: Colours.palette.m3outlineVariant + font: Tokens.font.title.large + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("This page will be available in a future update.") + color: Colours.palette.m3outlineVariant + font: Tokens.font.body.large + } + } + } +} diff --git a/modules/nexus/pages/wallandstyle/WallpaperCategory.qml b/modules/nexus/pages/wallandstyle/WallpaperCategory.qml new file mode 100644 index 00000000..7b35fb3a --- /dev/null +++ b/modules/nexus/pages/wallandstyle/WallpaperCategory.qml @@ -0,0 +1,53 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import Caelestia.Models +import qs.services +import qs.modules.nexus.common + +PageBase { + id: root + + title: { + const c = nState.selectedWallpaperCategory; + return c.slice(0, 1).toUpperCase() + c.slice(1); + } + isSubPage: true + + GridLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + + columns: Config.nexus.wallpapersPerRow + rowSpacing: Tokens.spacing.medium + columnSpacing: Tokens.spacing.large + + Repeater { + model: { + const walls = Wallpapers.list.filter(w => Wallpapers.getCategoryFor(w) === root.nState.selectedWallpaperCategory).sort((a, b) => a.name.localeCompare(b.name)); + while (walls.length < Config.nexus.wallpapersPerRow) + walls.push(null); + return walls; + } + + WallItem { + required property FileSystemEntry modelData + + // Empty placeholders for sizing + opacity: modelData ? 1 : 0 + enabled: modelData + + source: String(modelData?.path ?? "") + text: modelData?.name ?? "" + onClicked: { + Wallpapers.setWallpaper(modelData.path); + root.nState.closeSubPage(); + root.nState.closeSubPage(); + } + } + } + } +} diff --git a/modules/nexus/pages/wallandstyle/WallpaperSelect.qml b/modules/nexus/pages/wallandstyle/WallpaperSelect.qml new file mode 100644 index 00000000..83e0d73a --- /dev/null +++ b/modules/nexus/pages/wallandstyle/WallpaperSelect.qml @@ -0,0 +1,184 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Caelestia.Components +import Caelestia.Config +import Caelestia.Models +import qs.components +import qs.components.controls +import qs.components.filedialog +import qs.services +import qs.utils +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Wallpapers") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.small + + ButtonRow { + Layout.bottomMargin: Tokens.spacing.medium + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + IconTextButton { + icon: "photo_library" + text: qsTr("Browse") + font: Tokens.font.body.large + isRound: true + shapeMorph: true + horizontalPadding: Tokens.padding.extraLarge + verticalPadding: Tokens.padding.medium + onClicked: browseDialog.open() + + FileDialog { + id: browseDialog + + title: qsTr("Select an image") + filterLabel: qsTr("Image files") + filters: Images.validImageExtensions + onAccepted: path => { + Wallpapers.setWallpaper(path); + root.nState.closeSubPage(); + } + } + } + + IconTextButton { + icon: "shuffle" + text: qsTr("Random") + font: Tokens.font.body.large + isRound: true + shapeMorph: true + horizontalPadding: Tokens.padding.extraLarge + verticalPadding: Tokens.padding.medium + type: IconTextButton.Tonal + onClicked: { + Wallpapers.setRandom(); + root.nState.closeSubPage(); + } + } + } + + WallItem { + imgHeight: Math.round(width * 0.3) + radius: Tokens.rounding.extraLarge + source: Quickshell.shellPath("assets/wallpaper.webp") + text: qsTr("Featured wallpaper") + fillLabel: false + onClicked: { + Wallpapers.setWallpaper(Quickshell.shellPath("assets/wallpaper.webp")); + root.nState.closeSubPage(); + } + } + + StyledText { + Layout.topMargin: Tokens.spacing.large + text: qsTr("Local wallpapers") + font: Tokens.font.title.small + } + + GridLayout { + Layout.fillWidth: true + visible: localWalls.count > 0 + + columns: Config.nexus.wallpapersPerRow + rowSpacing: Tokens.spacing.medium + columnSpacing: Tokens.spacing.large + + Repeater { + id: localWalls + + model: { + const walls = Wallpapers.list; + const baseDir = Paths.wallsdir; + const categories = {}; + for (const w of walls) { + if (w.parentDir !== baseDir) { + const category = Wallpapers.getCategoryFor(w); + if (category && (!(category in categories) || categories[category].name.localeCompare(w.name) > 0)) + categories[category] = w; + } + } + const cats = Object.values(categories); + while (cats.length < Config.nexus.wallpapersPerRow) + cats.push(null); + return cats; + } + + WallItem { + required property FileSystemEntry modelData + + // Empty placeholders for sizing + opacity: modelData ? 1 : 0 + enabled: modelData + + source: String(modelData?.path ?? "") + text: { + if (!modelData) + return ""; + + if (modelData.parentDir !== Paths.wallsdir) { + const category = Wallpapers.getCategoryFor(modelData); + return category.slice(0, 1).toUpperCase() + category.slice(1); + } + return modelData.name; + } + onClicked: { + if (modelData.parentDir !== Paths.wallsdir) { + root.nState.selectedWallpaperCategory = Wallpapers.getCategoryFor(modelData); + root.nState.openSubPage(2); // Category page + } else { + Wallpapers.setWallpaper(modelData.path); + root.nState.closeSubPage(); + } + } + } + } + } + + Loader { + Layout.fillWidth: true + + asynchronous: true + active: localWalls.count === 0 + visible: active + + sourceComponent: StyledRect { + color: Colours.tPalette.m3surfaceContainer + radius: Tokens.rounding.extraLarge + implicitHeight: noWallsLayout.implicitHeight + Tokens.padding.extraExtraLarge * 2 + + ColumnLayout { + id: noWallsLayout + + anchors.centerIn: parent + spacing: Tokens.spacing.extraSmall + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "hide_image" + color: Colours.palette.m3outline + fontStyle: Tokens.font.icon.extraLarge + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No local wallpapers found") + color: Colours.palette.m3outline + font: Tokens.font.title.small + } + } + } + } + } +} diff --git a/modules/utilities/cards/Toggles.qml b/modules/utilities/cards/Toggles.qml index 51a59368..d145700a 100644 --- a/modules/utilities/cards/Toggles.qml +++ b/modules/utilities/cards/Toggles.qml @@ -8,6 +8,7 @@ import Caelestia.Config import qs.components import qs.components.controls import qs.services +import qs.modules.nexus import qs.modules.bar.popouts as BarPopouts StyledRect { @@ -118,7 +119,7 @@ StyledRect { isToggle: false onClicked: { root.visibilities.utilities = false; - root.popouts.detach("network"); + WindowFactory.create(); } } } diff --git a/plugin/src/Caelestia/CMakeLists.txt b/plugin/src/Caelestia/CMakeLists.txt index 0ae917bd..ea2ee748 100644 --- a/plugin/src/Caelestia/CMakeLists.txt +++ b/plugin/src/Caelestia/CMakeLists.txt @@ -15,6 +15,9 @@ qml_module(caelestia PkgConfig::Qalculate ) +# Bake the shell version in so CUtils can expose it without spawning a process +target_compile_definitions(caelestia PRIVATE CAELESTIA_VERSION="${VERSION}") + add_subdirectory(Components) add_subdirectory(Config) add_subdirectory(Internal) diff --git a/plugin/src/Caelestia/Config/CMakeLists.txt b/plugin/src/Caelestia/Config/CMakeLists.txt index b6f4ef08..75593177 100644 --- a/plugin/src/Caelestia/Config/CMakeLists.txt +++ b/plugin/src/Caelestia/Config/CMakeLists.txt @@ -20,6 +20,7 @@ qml_module(caelestia-config generalconfig.hpp launcherconfig.hpp lockconfig.hpp + nexusconfig.hpp notifsconfig.hpp osdconfig.hpp serviceconfig.hpp diff --git a/plugin/src/Caelestia/Config/appearanceconfig.hpp b/plugin/src/Caelestia/Config/appearanceconfig.hpp index a5a95f3f..17e18aa1 100644 --- a/plugin/src/Caelestia/Config/appearanceconfig.hpp +++ b/plugin/src/Caelestia/Config/appearanceconfig.hpp @@ -221,7 +221,7 @@ public: m_label->setDefaultFamily(sans); m_label->large()->setDefaults(14, QFont::Medium, vaxes); m_label->medium()->setDefaults(12, QFont::Medium, vaxes); - m_label->small()->setDefaults(11, QFont::Medium, vaxes); + m_label->small()->setDefaults(11, QFont::Normal, vaxes); m_mono->setDefaultFamily(mono); m_mono->large()->setDefaults(16, QFont::Normal); diff --git a/plugin/src/Caelestia/Config/config.cpp b/plugin/src/Caelestia/Config/config.cpp index ecd63bb5..6e15f1d2 100644 --- a/plugin/src/Caelestia/Config/config.cpp +++ b/plugin/src/Caelestia/Config/config.cpp @@ -9,6 +9,7 @@ #include "launcherconfig.hpp" #include "lockconfig.hpp" #include "monitorconfigmanager.hpp" +#include "nexusconfig.hpp" #include "notifsconfig.hpp" #include "osdconfig.hpp" #include "serviceconfig.hpp" @@ -42,14 +43,15 @@ GlobalConfig::GlobalConfig(QObject* parent) , m_dashboard(new DashboardConfig(this)) , m_controlCenter(new ControlCenterConfig(this)) , m_launcher(new LauncherConfig(this)) + , m_lock(new LockConfig(this)) + , m_nexus(new NexusConfig(this)) , m_notifs(new NotifsConfig(this)) , m_osd(new OsdConfig(this)) - , m_session(new SessionConfig(this)) - , m_winfo(new WInfoConfig(this)) - , m_lock(new LockConfig(this)) - , m_utilities(new UtilitiesConfig(this)) - , m_sidebar(new SidebarConfig(this)) , m_services(new ServiceConfig(this)) + , m_session(new SessionConfig(this)) + , m_sidebar(new SidebarConfig(this)) + , m_utilities(new UtilitiesConfig(this)) + , m_winfo(new WInfoConfig(this)) , m_paths(new UserPaths(this)) { setupFileBackend(configDir() + QStringLiteral("shell.json")); } @@ -64,14 +66,15 @@ GlobalConfig::GlobalConfig(GlobalConfig* fallback, const QString& filePath, cons , m_dashboard(new DashboardConfig(this)) , m_controlCenter(new ControlCenterConfig(this)) , m_launcher(new LauncherConfig(this)) + , m_lock(new LockConfig(this)) + , m_nexus(new NexusConfig(this)) , m_notifs(new NotifsConfig(this)) , m_osd(new OsdConfig(this)) - , m_session(new SessionConfig(this)) - , m_winfo(new WInfoConfig(this)) - , m_lock(new LockConfig(this)) - , m_utilities(new UtilitiesConfig(this)) - , m_sidebar(new SidebarConfig(this)) , m_services(new ServiceConfig(this)) + , m_session(new SessionConfig(this)) + , m_sidebar(new SidebarConfig(this)) + , m_utilities(new UtilitiesConfig(this)) + , m_winfo(new WInfoConfig(this)) , m_paths(new UserPaths(this)) { if (!filePath.isEmpty()) setupFileBackend(filePath, screen); diff --git a/plugin/src/Caelestia/Config/config.hpp b/plugin/src/Caelestia/Config/config.hpp index 248a3f22..5b5cd9a0 100644 --- a/plugin/src/Caelestia/Config/config.hpp +++ b/plugin/src/Caelestia/Config/config.hpp @@ -15,6 +15,7 @@ class DashboardConfig; class GeneralConfig; class LauncherConfig; class LockConfig; +class NexusConfig; class NotifsConfig; class OsdConfig; class ServiceConfig; @@ -37,6 +38,7 @@ class GlobalConfig : public RootConfig { Q_MOC_INCLUDE("generalconfig.hpp") Q_MOC_INCLUDE("launcherconfig.hpp") Q_MOC_INCLUDE("lockconfig.hpp") + Q_MOC_INCLUDE("nexusconfig.hpp") Q_MOC_INCLUDE("notifsconfig.hpp") Q_MOC_INCLUDE("osdconfig.hpp") Q_MOC_INCLUDE("serviceconfig.hpp") @@ -55,14 +57,15 @@ class GlobalConfig : public RootConfig { CONFIG_SUBOBJECT(DashboardConfig, dashboard) CONFIG_SUBOBJECT(ControlCenterConfig, controlCenter) CONFIG_SUBOBJECT(LauncherConfig, launcher) + CONFIG_SUBOBJECT(LockConfig, lock) + CONFIG_SUBOBJECT(NexusConfig, nexus) CONFIG_SUBOBJECT(NotifsConfig, notifs) CONFIG_SUBOBJECT(OsdConfig, osd) - CONFIG_SUBOBJECT(SessionConfig, session) - CONFIG_SUBOBJECT(WInfoConfig, winfo) - CONFIG_SUBOBJECT(LockConfig, lock) - CONFIG_SUBOBJECT(UtilitiesConfig, utilities) - CONFIG_SUBOBJECT(SidebarConfig, sidebar) CONFIG_SUBOBJECT(ServiceConfig, services) + CONFIG_SUBOBJECT(SessionConfig, session) + CONFIG_SUBOBJECT(SidebarConfig, sidebar) + CONFIG_SUBOBJECT(UtilitiesConfig, utilities) + CONFIG_SUBOBJECT(WInfoConfig, winfo) CONFIG_SUBOBJECT(UserPaths, paths) public: diff --git a/plugin/src/Caelestia/Config/configattached.cpp b/plugin/src/Caelestia/Config/configattached.cpp index 8d229049..b5420b69 100644 --- a/plugin/src/Caelestia/Config/configattached.cpp +++ b/plugin/src/Caelestia/Config/configattached.cpp @@ -73,14 +73,15 @@ CONFIG_ATTACHED_GETTER(BorderConfig, border) CONFIG_ATTACHED_GETTER(DashboardConfig, dashboard) CONFIG_ATTACHED_GETTER(ControlCenterConfig, controlCenter) CONFIG_ATTACHED_GETTER(LauncherConfig, launcher) +CONFIG_ATTACHED_GETTER(LockConfig, lock) +CONFIG_ATTACHED_GETTER(NexusConfig, nexus) CONFIG_ATTACHED_GETTER(NotifsConfig, notifs) CONFIG_ATTACHED_GETTER(OsdConfig, osd) -CONFIG_ATTACHED_GETTER(SessionConfig, session) -CONFIG_ATTACHED_GETTER(WInfoConfig, winfo) -CONFIG_ATTACHED_GETTER(LockConfig, lock) -CONFIG_ATTACHED_GETTER(UtilitiesConfig, utilities) -CONFIG_ATTACHED_GETTER(SidebarConfig, sidebar) CONFIG_ATTACHED_GETTER(ServiceConfig, services) +CONFIG_ATTACHED_GETTER(SessionConfig, session) +CONFIG_ATTACHED_GETTER(SidebarConfig, sidebar) +CONFIG_ATTACHED_GETTER(UtilitiesConfig, utilities) +CONFIG_ATTACHED_GETTER(WInfoConfig, winfo) CONFIG_ATTACHED_GETTER(UserPaths, paths) #undef CONFIG_ATTACHED_GETTER diff --git a/plugin/src/Caelestia/Config/configattached.hpp b/plugin/src/Caelestia/Config/configattached.hpp index 815b1080..19054bde 100644 --- a/plugin/src/Caelestia/Config/configattached.hpp +++ b/plugin/src/Caelestia/Config/configattached.hpp @@ -21,6 +21,7 @@ class Config : public QQuickAttachedPropertyPropagator, public QQmlParserStatus Q_MOC_INCLUDE("generalconfig.hpp") Q_MOC_INCLUDE("launcherconfig.hpp") Q_MOC_INCLUDE("lockconfig.hpp") + Q_MOC_INCLUDE("nexusconfig.hpp") Q_MOC_INCLUDE("notifsconfig.hpp") Q_MOC_INCLUDE("osdconfig.hpp") Q_MOC_INCLUDE("serviceconfig.hpp") @@ -39,14 +40,15 @@ class Config : public QQuickAttachedPropertyPropagator, public QQmlParserStatus Q_PROPERTY(const caelestia::config::DashboardConfig* dashboard READ dashboard NOTIFY sourceChanged) Q_PROPERTY(const caelestia::config::ControlCenterConfig* controlCenter READ controlCenter NOTIFY sourceChanged) Q_PROPERTY(const caelestia::config::LauncherConfig* launcher READ launcher NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::LockConfig* lock READ lock NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::NexusConfig* nexus READ nexus NOTIFY sourceChanged) Q_PROPERTY(const caelestia::config::NotifsConfig* notifs READ notifs NOTIFY sourceChanged) Q_PROPERTY(const caelestia::config::OsdConfig* osd READ osd NOTIFY sourceChanged) - Q_PROPERTY(const caelestia::config::SessionConfig* session READ session NOTIFY sourceChanged) - Q_PROPERTY(const caelestia::config::WInfoConfig* winfo READ winfo NOTIFY sourceChanged) - Q_PROPERTY(const caelestia::config::LockConfig* lock READ lock NOTIFY sourceChanged) - Q_PROPERTY(const caelestia::config::UtilitiesConfig* utilities READ utilities NOTIFY sourceChanged) - Q_PROPERTY(const caelestia::config::SidebarConfig* sidebar READ sidebar NOTIFY sourceChanged) Q_PROPERTY(const caelestia::config::ServiceConfig* services READ services NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::SessionConfig* session READ session NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::SidebarConfig* sidebar READ sidebar NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::UtilitiesConfig* utilities READ utilities NOTIFY sourceChanged) + Q_PROPERTY(const caelestia::config::WInfoConfig* winfo READ winfo NOTIFY sourceChanged) Q_PROPERTY(const caelestia::config::UserPaths* paths READ paths NOTIFY sourceChanged) public: @@ -63,14 +65,15 @@ public: [[nodiscard]] const DashboardConfig* dashboard() const; [[nodiscard]] const ControlCenterConfig* controlCenter() const; [[nodiscard]] const LauncherConfig* launcher() const; + [[nodiscard]] const LockConfig* lock() const; + [[nodiscard]] const NexusConfig* nexus() const; [[nodiscard]] const NotifsConfig* notifs() const; [[nodiscard]] const OsdConfig* osd() const; - [[nodiscard]] const SessionConfig* session() const; - [[nodiscard]] const WInfoConfig* winfo() const; - [[nodiscard]] const LockConfig* lock() const; - [[nodiscard]] const UtilitiesConfig* utilities() const; - [[nodiscard]] const SidebarConfig* sidebar() const; [[nodiscard]] const ServiceConfig* services() const; + [[nodiscard]] const SessionConfig* session() const; + [[nodiscard]] const SidebarConfig* sidebar() const; + [[nodiscard]] const UtilitiesConfig* utilities() const; + [[nodiscard]] const WInfoConfig* winfo() const; [[nodiscard]] const UserPaths* paths() const; [[nodiscard]] Q_INVOKABLE static GlobalConfig* forScreen(const QString& screen); diff --git a/plugin/src/Caelestia/Config/nexusconfig.hpp b/plugin/src/Caelestia/Config/nexusconfig.hpp new file mode 100644 index 00000000..2389acb3 --- /dev/null +++ b/plugin/src/Caelestia/Config/nexusconfig.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "configobject.hpp" + +namespace caelestia::config { + +class NexusConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(int, wallpapersPerRow, 4) + CONFIG_GLOBAL_PROPERTY(int, networkRescanInterval, 15000) + +public: + explicit NexusConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/serviceconfig.hpp b/plugin/src/Caelestia/Config/serviceconfig.hpp index 60bbced1..ffdccda6 100644 --- a/plugin/src/Caelestia/Config/serviceconfig.hpp +++ b/plugin/src/Caelestia/Config/serviceconfig.hpp @@ -32,7 +32,6 @@ class ServiceConfig : public ConfigObject { CONFIG_GLOBAL_PROPERTY(QString, defaultPlayer, u"Spotify"_s) CONFIG_GLOBAL_PROPERTY(QVariantList, playerAliases, { vmap({ { u"from"_s, u"com.github.th_ch.youtube_music"_s }, { u"to"_s, u"YT Music"_s } }) }) - CONFIG_GLOBAL_PROPERTY(bool, showLyrics, false) CONFIG_GLOBAL_PROPERTY(QString, lyricsBackend, u"Auto"_s) public: diff --git a/plugin/src/Caelestia/Config/tokens.hpp b/plugin/src/Caelestia/Config/tokens.hpp index d29f62ee..3d0267f4 100644 --- a/plugin/src/Caelestia/Config/tokens.hpp +++ b/plugin/src/Caelestia/Config/tokens.hpp @@ -323,6 +323,21 @@ public: : ConfigObject(parent) {} }; +class NexusTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(qreal, heightMult, 0.7) + CONFIG_PROPERTY(qreal, ratio, 16.0 / 9.0) + CONFIG_PROPERTY(int, minWidth, 800) + CONFIG_PROPERTY(int, minHeight, 500) + CONFIG_PROPERTY(int, maxNavWidth, 600) + +public: + explicit NexusTokens(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + class SizeTokens : public ConfigObject { Q_OBJECT QML_ANONYMOUS @@ -338,6 +353,7 @@ class SizeTokens : public ConfigObject { CONFIG_SUBOBJECT(LockTokens, lock) CONFIG_SUBOBJECT(WInfoTokens, winfo) CONFIG_SUBOBJECT(ControlCenterTokens, controlCenter) + CONFIG_SUBOBJECT(NexusTokens, nexus) public: explicit SizeTokens(QObject* parent = nullptr) @@ -352,7 +368,8 @@ public: , m_utilities(new UtilitiesTokens(this)) , m_lock(new LockTokens(this)) , m_winfo(new WInfoTokens(this)) - , m_controlCenter(new ControlCenterTokens(this)) {} + , m_controlCenter(new ControlCenterTokens(this)) + , m_nexus(new NexusTokens(this)) {} }; class TokenConfig : public RootConfig { diff --git a/plugin/src/Caelestia/cutils.cpp b/plugin/src/Caelestia/cutils.cpp index 9c2426c2..b6080f6e 100644 --- a/plugin/src/Caelestia/cutils.cpp +++ b/plugin/src/Caelestia/cutils.cpp @@ -142,4 +142,16 @@ qreal CUtils::clamp(qreal value, qreal min, qreal max) { return qBound(min, value, max); } +#ifndef CAELESTIA_VERSION +#define CAELESTIA_VERSION "" +#endif + +QString CUtils::version() const { + return QStringLiteral(CAELESTIA_VERSION); +} + +QString CUtils::qtVersion() const { + return QStringLiteral(QT_VERSION_STR); +} + } // namespace caelestia diff --git a/plugin/src/Caelestia/cutils.hpp b/plugin/src/Caelestia/cutils.hpp index 29ae3ca5..7fb4163a 100644 --- a/plugin/src/Caelestia/cutils.hpp +++ b/plugin/src/Caelestia/cutils.hpp @@ -11,6 +11,9 @@ class CUtils : public QObject { QML_ELEMENT QML_SINGLETON + Q_PROPERTY(QString version READ version CONSTANT) + Q_PROPERTY(QString qtVersion READ qtVersion CONSTANT) + public: // clang-format off Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path); @@ -26,6 +29,9 @@ public: Q_INVOKABLE static QString toLocalFile(const QUrl& url); Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max); + + [[nodiscard]] QString version() const; + [[nodiscard]] QString qtVersion() const; }; } // namespace caelestia diff --git a/services/Nmcli.qml b/services/Nmcli.qml index ea5d7eb3..e9827149 100644 --- a/services/Nmcli.qml +++ b/services/Nmcli.qml @@ -12,6 +12,7 @@ Singleton { property var wirelessInterfaces: [] property var ethernetInterfaces: [] property bool isConnected: false + readonly property bool connecting: wirelessInterfaces.some(i => isConnectingState(i.state)) property string activeInterface: "" property string activeConnection: "" property bool wifiEnabled: true @@ -166,6 +167,15 @@ Singleton { return state === "100 (connected)" || state === "connected" || state.startsWith("connected"); } + function isConnectingState(state: string): bool { + return !!state && state.startsWith("connecting"); + } + + function connectingSsid(): string { + const iface = root.wirelessInterfaces.find(i => isConnectingState(i.state)); + return iface ? iface.connection : ""; + } + function executeCommand(args: list, callback: var): void { const proc = commandProc.createObject(root); proc.cmdArgs = ["nmcli", ...args]; diff --git a/services/Wallpapers.qml b/services/Wallpapers.qml index 15daf5c1..a56ff348 100644 --- a/services/Wallpapers.qml +++ b/services/Wallpapers.qml @@ -20,6 +20,17 @@ Searcher { property string actualCurrent property bool previewColourLock + function getCategoryFor(w: FileSystemEntry): string { + let category = w.parentDir.slice(Paths.wallsdir.length + 1); + if (category.includes("/")) + category = category.slice(0, category.indexOf("/")); + return category; + } + + function setRandom(): void { + Quickshell.execDetached(["caelestia", "wallpaper", "-r", ...smartArg]); + } + function setWallpaper(path: string): void { actualCurrent = path; Quickshell.execDetached(["caelestia", "wallpaper", "-f", path, ...smartArg]); diff --git a/utils/SysInfo.qml b/utils/SysInfo.qml index c715b8d2..c18ea560 100644 --- a/utils/SysInfo.qml +++ b/utils/SysInfo.qml @@ -21,6 +21,28 @@ Singleton { readonly property string wm: Quickshell.env("XDG_CURRENT_DESKTOP") || Quickshell.env("XDG_SESSION_DESKTOP") readonly property string shell: Quickshell.env("SHELL").split("/").pop() + property string kernel + property string hostname + property string firmware + + // DMI vendor/model, combined into a single human-readable device name + property string boardVendor + property string boardName + readonly property string device: { + if (!boardName) + return boardVendor; + if (!boardVendor || boardName.toLowerCase().startsWith(boardVendor.toLowerCase())) + return boardName; + return `${boardVendor} ${boardName}`; + } + + // Strips the placeholder strings OEMs commonly leave in DMI fields + function sanitiseDmi(s: string): string { + const t = s.trim(); + const junk = ["to be filled by o.e.m.", "system product name", "system manufacturer", "system version", "default string", "o.e.m.", "not specified", "not applicable", "unknown", "none", ""]; + return junk.includes(t.toLowerCase()) ? "" : t; + } + FileView { id: osRelease @@ -57,6 +79,34 @@ Singleton { target: GlobalConfig.general } + FileView { + path: "/proc/sys/kernel/osrelease" + onLoaded: root.kernel = text().trim() + } + + FileView { + path: "/proc/sys/kernel/hostname" + onLoaded: root.hostname = text().trim() + } + + FileView { + path: "/sys/class/dmi/id/sys_vendor" + printErrors: false + onLoaded: root.boardVendor = root.sanitiseDmi(text()) + } + + FileView { + path: "/sys/class/dmi/id/product_name" + printErrors: false + onLoaded: root.boardName = root.sanitiseDmi(text()) + } + + FileView { + path: "/sys/class/dmi/id/bios_version" + printErrors: false + onLoaded: root.firmware = root.sanitiseDmi(text()) + } + Timer { running: true repeat: true