diff --git a/modules/BatteryMonitor.qml b/modules/BatteryMonitor.qml index 2d13b5a32..4102b366f 100644 --- a/modules/BatteryMonitor.qml +++ b/modules/BatteryMonitor.qml @@ -3,22 +3,218 @@ import Quickshell import Quickshell.Services.UPower import Caelestia import Caelestia.Config +import qs.services Scope { id: root readonly property list warnLevels: [...GlobalConfig.general.battery.warnLevels].sort((a, b) => b.level - a.level) + readonly property var pm: GlobalConfig.general.battery.powerManagement + readonly property bool pmEnabled: pm && pm.enabled === true + readonly property list powerThresholds: { + const t = (pm && pm.thresholds) || []; + return [...t].sort((a, b) => (b.level ?? 0) - (a.level ?? 0)); + } + + property var originalRefreshRates: ({}) + property int currentThresholdIndex: -1 + property bool settingsModified: false + property bool initialized: false + + function applyVisualEffects(settings): void { + const options = {}; + if (settings.disableAnimations === "disable") + options["animations:enabled"] = 0; + else if (settings.disableAnimations === "enable") + options["animations:enabled"] = 1; + if (settings.disableBlur === "disable") + options["decoration:blur:enabled"] = 0; + else if (settings.disableBlur === "enable") + options["decoration:blur:enabled"] = 1; + if (settings.disableRounding === "disable") + options["decoration:rounding"] = 0; + else if (settings.disableRounding === "enable") + options["decoration:rounding"] = GlobalConfig.appearance.rounding.normal; + if (settings.disableShadows === "disable") + options["decoration:shadow:enabled"] = 0; + else if (settings.disableShadows === "enable") + options["decoration:shadow:enabled"] = 1; + if (Object.keys(options).length > 0) + Hypr.extras.applyOptions(options); // qmllint disable missing-property + } + + function applyRefreshRate(rate): void { + if (rate === "restore") { + restoreRefreshRates(); + return; + } + const targetRate = rate === "auto" ? getLowestRefreshRate() : rate; + const monitors = Object.values(Hypr.monitors.values || Hypr.monitors); + for (const monitor of monitors) { + const data = monitor.lastIpcObject; + if (data) + Hypr.extras.message(`keyword monitor ${data.name},${data.width}x${data.height}@${targetRate},${data.x}x${data.y},${data.scale}`); // qmllint disable missing-property + } + } + + function getLowestRefreshRate(): real { + const monitors = Object.values(Hypr.monitors.values || Hypr.monitors); + let lowestRate = 60; + for (const monitor of monitors) { + const data = monitor.lastIpcObject; + if (data && data.availableModes && data.availableModes.length > 0) { + const rates = []; + for (const mode of data.availableModes) { + const m = mode.match(/@(\d+(?:\.\d+)?)Hz/); + if (m) { + const r = Math.round(parseFloat(m[1])); + if (!rates.includes(r)) + rates.push(r); + } + } + rates.sort((a, b) => a - b); + if (rates.length > 0) + lowestRate = Math.min(lowestRate, rates[0]); + } + } + return lowestRate; + } + + function saveOriginalSettings(): void { + const monitors = Object.values(Hypr.monitors.values || Hypr.monitors); + const next = {}; + for (const monitor of monitors) { + const data = monitor.lastIpcObject; + if (data) + next[data.name] = data.refreshRate; + } + root.originalRefreshRates = next; + } + + function restoreRefreshRates(): void { + const monitors = Object.values(Hypr.monitors.values || Hypr.monitors); + for (const monitor of monitors) { + const data = monitor.lastIpcObject; + if (data && root.originalRefreshRates[data.name]) { + const orig = root.originalRefreshRates[data.name]; + Hypr.extras.message(`keyword monitor ${data.name},${data.width}x${data.height}@${orig},${data.x}x${data.y},${data.scale}`); // qmllint disable missing-property + } + } + } + + function setPowerProfile(name): void { + const map = { + "power-saver": PowerProfile.PowerSaver, + "balanced": PowerProfile.Balanced, + "performance": PowerProfile.Performance + }; + if (map[name] !== undefined) + PowerProfiles.profile = map[name]; + } + + function handleUnpluggedState(): void { + const cfg = (root.pm && root.pm.onUnplugged) || {}; + const hasActions = (cfg.setPowerProfile ?? "") !== "" || (cfg.setRefreshRate ?? "") !== "" || (cfg.disableAnimations ?? "") !== "" || (cfg.disableBlur ?? "") !== "" || (cfg.disableRounding ?? "") !== "" || (cfg.disableShadows ?? "") !== ""; + if (hasActions) { + if (!root.settingsModified) + root.saveOriginalSettings(); + if ((cfg.setPowerProfile ?? "") !== "") + root.setPowerProfile(cfg.setPowerProfile); + root.applyVisualEffects(cfg); + if ((cfg.setRefreshRate ?? "") !== "") + root.applyRefreshRate(cfg.setRefreshRate); + root.settingsModified = true; + } + if (cfg.evaluateThresholds !== false) + root.evaluateThresholds(); + } + + function handleChargingState(): void { + const cfg = (root.pm && root.pm.onCharging) || {}; + if (cfg.setPowerProfile === "restore") + PowerProfiles.profile = PowerProfile.Balanced; + else if ((cfg.setPowerProfile ?? "") !== "") + root.setPowerProfile(cfg.setPowerProfile); + if ((cfg.setRefreshRate ?? "") !== "" && cfg.setRefreshRate !== "unchanged") + root.applyRefreshRate(cfg.setRefreshRate); + root.applyVisualEffects(cfg); + root.settingsModified = false; + root.currentThresholdIndex = -1; + } + + function evaluateThresholds(): void { + if (!UPower.onBattery || !root.pmEnabled) + return; + const p = UPower.displayDevice.percentage * 100; + let target = -1; + for (let i = 0; i < root.powerThresholds.length; i++) { + if (p <= (root.powerThresholds[i].level ?? 0)) { + target = i; + break; + } + } + if (target !== root.currentThresholdIndex) { + root.currentThresholdIndex = target; + if (target >= 0) + root.applyThreshold(root.powerThresholds[target]); + } + } + + function applyThreshold(threshold): void { + if (!root.settingsModified) + root.saveOriginalSettings(); + if ((threshold.setPowerProfile ?? "") !== "") + root.setPowerProfile(threshold.setPowerProfile); + root.applyVisualEffects(threshold); + if ((threshold.setRefreshRate ?? "") !== "") + root.applyRefreshRate(threshold.setRefreshRate); + root.settingsModified = true; + + if (GlobalConfig.utilities.toasts.lowPowerModeChanged && root.initialized) { + const actions = []; + if ((threshold.setPowerProfile ?? "") !== "") + actions.push(qsTr("profile: ") + threshold.setPowerProfile); + if ((threshold.setRefreshRate ?? "") !== "") + actions.push(threshold.setRefreshRate === "auto" ? qsTr("lowest Hz") : threshold.setRefreshRate + "Hz"); + if (threshold.disableAnimations === "disable") + actions.push(qsTr("no animations")); + if (threshold.disableBlur === "disable") + actions.push(qsTr("no blur")); + Toaster.toast(qsTr("Battery saving active"), qsTr("Applied: ") + actions.join(", "), "battery_saver"); + } + } + + Component.onCompleted: initTimer.start() + + Timer { + id: initTimer + + interval: 1000 + onTriggered: root.initialized = true + } + + Timer { + id: hibernateTimer + + interval: 5000 + onTriggered: Quickshell.execDetached(["systemctl", "hibernate"]) + } + Connections { function onOnBatteryChanged(): void { if (UPower.onBattery) { - if (GlobalConfig.utilities.toasts.chargingChanged) + if (GlobalConfig.utilities.toasts.chargingChanged && root.initialized) Toaster.toast(qsTr("Charger unplugged"), qsTr("Battery is discharging"), "power_off"); + if (root.pmEnabled) + root.handleUnpluggedState(); } else { - if (GlobalConfig.utilities.toasts.chargingChanged) + if (GlobalConfig.utilities.toasts.chargingChanged && root.initialized) Toaster.toast(qsTr("Charger plugged in"), qsTr("Battery is charging"), "power"); for (const level of root.warnLevels) level.warned = false; + if (root.pmEnabled) + root.handleChargingState(); } } @@ -42,15 +238,57 @@ Scope { Toaster.toast(qsTr("Hibernating in 5 seconds"), qsTr("Hibernating to prevent data loss"), "battery_android_alert", Toast.Error); hibernateTimer.start(); } + + if (root.pmEnabled) + root.evaluateThresholds(); } target: UPower.displayDevice } - Timer { - id: hibernateTimer + Connections { + function onProfileChanged(): void { + if (!root.pmEnabled) + return; - interval: 5000 - onTriggered: Quickshell.execDetached(["systemctl", "hibernate"]) + const profileBehaviors = (root.pm && root.pm.profileBehaviors) || {}; + let behavior = null; + let profileName = ""; + if (PowerProfiles.profile === PowerProfile.PowerSaver) { + behavior = profileBehaviors.powerSaver; + profileName = qsTr("Power Saver"); + } else if (PowerProfiles.profile === PowerProfile.Balanced) { + behavior = profileBehaviors.balanced; + profileName = qsTr("Balanced"); + } else if (PowerProfiles.profile === PowerProfile.Performance) { + behavior = profileBehaviors.performance; + profileName = qsTr("Performance"); + } + if (!behavior) + return; + + if (behavior.setRefreshRate && behavior.setRefreshRate !== "" && behavior.setRefreshRate !== "unchanged") + root.applyRefreshRate(behavior.setRefreshRate); + root.applyVisualEffects(behavior); + + const hasSettings = (behavior.disableAnimations ?? "") !== "" || (behavior.disableBlur ?? "") !== "" || (behavior.disableRounding ?? "") !== "" || (behavior.disableShadows ?? "") !== "" || (behavior.setRefreshRate && behavior.setRefreshRate !== "" && behavior.setRefreshRate !== "restore"); + if (GlobalConfig.utilities.toasts.lowPowerModeChanged && hasSettings && root.initialized) { + const actions = []; + if (behavior.setRefreshRate && behavior.setRefreshRate !== "") + actions.push(behavior.setRefreshRate === "auto" ? qsTr("lowest Hz") : behavior.setRefreshRate + "Hz"); + if (behavior.disableAnimations === "disable") + actions.push(qsTr("no animations")); + else if (behavior.disableAnimations === "enable") + actions.push(qsTr("animations on")); + if (behavior.disableBlur === "disable") + actions.push(qsTr("no blur")); + else if (behavior.disableBlur === "enable") + actions.push(qsTr("blur on")); + if (actions.length > 0) + Toaster.toast(profileName + qsTr(" profile"), qsTr("Applied: ") + actions.join(", "), "battery_saver"); + } + } + + target: PowerProfiles } } diff --git a/modules/Shortcuts.qml b/modules/Shortcuts.qml index 9ee796769..5f336012c 100644 --- a/modules/Shortcuts.qml +++ b/modules/Shortcuts.qml @@ -5,6 +5,7 @@ import Caelestia import qs.components.misc import qs.services import qs.modules.controlcenter +import qs.modules.nexus Scope { id: root @@ -20,6 +21,14 @@ Scope { onPressed: WindowFactory.create() } + // qmllint disable unresolved-type + CustomShortcut { + // qmllint enable unresolved-type + name: "nexus" + description: "Open Nexus settings" + onPressed: NexusWindowFactory.create() + } + // qmllint disable unresolved-type CustomShortcut { // qmllint enable unresolved-type @@ -136,6 +145,14 @@ Scope { target: "controlCenter" } + IpcHandler { + function open(): void { + NexusWindowFactory.create(); + } + + target: "nexus" + } + IpcHandler { function info(title: string, message: string, icon: string): void { Toaster.toast(title, message, icon, Toast.Info); diff --git a/modules/drawers/ContentWindow.qml b/modules/drawers/ContentWindow.qml index eb44ebbaf..4c97a69c0 100644 --- a/modules/drawers/ContentWindow.qml +++ b/modules/drawers/ContentWindow.qml @@ -124,6 +124,10 @@ StyledWindow { shadowColor: Qt.alpha(Colours.palette.m3shadow, Math.max(0, root.shadowOpacity)) } + Behavior on opacity { + Anim {} + } + BlobGroup { id: blobGroup diff --git a/modules/nexus/ContentArea.qml b/modules/nexus/ContentArea.qml new file mode 100644 index 000000000..e3bf0faad --- /dev/null +++ b/modules/nexus/ContentArea.qml @@ -0,0 +1,321 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus +import qs.modules.nexus.components //qmllint disable unused-imports +import qs.modules.nexus.components.power //qmllint disable unused-imports +import qs.modules.nexus.components.common //qmllint disable unused-imports + +Item { + id: root + + required property NexusSession session + + readonly property var activeConfig: NexusRegistry.getById(session.activeCategory) + readonly property var tabs: activeConfig ? NexusRegistry.getCategoryTabs(activeConfig.id) : [] + + property int activeTabIndex: 0 + property string _prevCategory: "" + property var _prevConfig: null + property var _prevTabs: [] + property bool _categoryTransitioning: false + property real _slideOffset: 0 + + function updateTabIndicator() { + const item = tabRepeater.itemAt(activeTabIndex); + if (item) { + tabIndicator.targetX = item.x; + tabIndicator.targetWidth = item.width; + } else { + tabIndicator.targetX = 0; + tabIndicator.targetWidth = 0; + } + } + + function onForcedTabChanged() { + if (session.forcedTab !== "") { + const tabList = root.tabs; + for (let i = 0; i < tabList.length; i++) { + if (tabList[i] === session.forcedTab) { + root.activeTabIndex = i; + break; + } + } + session.consumeForcedTab(); + } + } + + onActiveConfigChanged: { + if (session.activeCategory === "") { + _prevCategory = ""; + _prevConfig = null; + _prevTabs = []; + activeTabIndex = 0; + contentContainer.opacity = 0; + _slideOffset = 0; + _categoryTransitioning = false; + } else if (_prevCategory === "") { + _prevCategory = session.activeCategory; + _prevConfig = activeConfig; + _prevTabs = tabs; + activeTabIndex = 0; + contentFadeOut.stop(); + contentContainer.opacity = 0; + _slideOffset = contentContainer.height * 0.15; + contentFadeIn.restart(); + _categoryTransitioning = false; + } else { + _categoryTransitioning = true; + contentFadeOut.start(); + } + tabIndicatorUpdate.restart(); + } + + onActiveTabIndexChanged: { + tabIndicatorUpdate.restart(); + } + + Timer { + id: tabIndicatorUpdate + + interval: 0 + onTriggered: root.updateTabIndicator() + } + + Connections { + function onForcedTabChanged() { + root.onForcedTabChanged(); + } + + target: root.session + } + + ParallelAnimation { + id: contentFadeOut + + onFinished: { + root._prevCategory = root.session.activeCategory; + root._prevConfig = root.activeConfig; + root._prevTabs = root.tabs; + root.activeTabIndex = 0; + root._slideOffset = contentContainer.height * 0.15; + contentFadeIn.start(); + } + + NumberAnimation { + target: contentContainer + property: "opacity" + from: 1 + to: 0 + duration: 150 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root + property: "_slideOffset" + from: 0 + to: -contentContainer.height * 0.15 + duration: 150 + easing.type: Easing.InOutQuad + } + } + + ParallelAnimation { + id: contentFadeIn + + onFinished: { + root._categoryTransitioning = false; + } + + NumberAnimation { + target: contentContainer + property: "opacity" + from: 0 + to: 1 + duration: 250 + easing.type: Easing.InOutQuad + } + + NumberAnimation { + target: root + property: "_slideOffset" + from: contentContainer.height * 0.15 + to: 0 + duration: 250 + easing.type: Easing.InOutQuad + } + } + + ColumnLayout { + id: contentContainer + + anchors.fill: parent + anchors.margins: Tokens.padding.large * 3 + spacing: Tokens.spacing.normal + + opacity: 1 + + transform: Translate { + y: root._slideOffset // qmllint disable Quick.layout-positioning + } + + // Header: title + description + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Tokens.padding.smaller + + spacing: Tokens.spacing.small / 2 + + StyledText { + text: root._prevConfig?.label ?? "" + font.pointSize: Tokens.font.size.larger + 4 + font.weight: Font.DemiBold + color: Colours.palette.m3onSurface + } + + StyledText { + text: root._prevConfig?.description ?? "" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.7) + visible: root._prevConfig && root._prevConfig.description + } + } + + Item { + id: tabBar + + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.smaller + Layout.bottomMargin: Tokens.spacing.large + Layout.preferredHeight: 48 + visible: root._prevTabs.length > 0 + + // Track line + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: -8 + height: 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.1) + } + + Row { + id: tabRow + + anchors.left: parent.left + anchors.bottom: parent.bottom + spacing: 4 + + Repeater { + id: tabRepeater + + model: root._prevTabs + + delegate: Rectangle { + id: tabItem + + required property string modelData + required property int index + + width: tabLabel.implicitWidth + Tokens.padding.large * 2 + height: 48 + radius: Tokens.rounding.small + color: "transparent" + + onXChanged: if (tabItem.index === root.activeTabIndex) + tabIndicator.targetX = tabItem.x + onWidthChanged: if (tabItem.index === root.activeTabIndex) + tabIndicator.targetWidth = tabItem.width + + StyledText { + id: tabLabel + + anchors.centerIn: parent + text: tabItem.modelData + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: root.activeTabIndex === tabItem.index ? Colours.palette.m3primary : Colours.palette.m3onSurface + + Behavior on color { + CAnim {} + } + } + + StateLayer { + radius: Tokens.rounding.small + color: root.activeTabIndex === tabItem.index ? Colours.palette.m3primary : Colours.palette.m3onSurface + onClicked: root.activeTabIndex = tabItem.index + } + } + } + } + + Rectangle { + id: tabIndicator + + property real targetX: 0 + property real targetWidth: 0 + + anchors.bottom: parent.bottom + anchors.bottomMargin: -8 + height: 3 + radius: 1.5 + color: Colours.palette.m3primary + visible: root._prevTabs.length > 0 + + x: targetX + width: targetWidth + + Behavior on x { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on width { + Anim { + type: Anim.DefaultSpatial + } + } + } + } + + // Panel content - single loader, panel manages its own tabs + Loader { + id: panelLoader + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: Tokens.spacing.normal + + readonly property string panelId: root._prevConfig ? root._prevConfig.id.charAt(0).toUpperCase() + root._prevConfig.id.slice(1) : "" + readonly property string targetSource: panelId ? "panels/" + panelId + "/Main.qml" : "" + property string resolvedSource: targetSource + + asynchronous: true + source: resolvedSource + + onTargetSourceChanged: resolvedSource = targetSource + + onStatusChanged: { + if (status === Loader.Error && resolvedSource !== "panels/PlaceholderPanel.qml") { + Qt.callLater(() => { + resolvedSource = "panels/PlaceholderPanel.qml"; + }); + } + } + + Binding { + target: panelLoader.item + property: "activeTabIndex" + value: root.activeTabIndex + when: panelLoader.item && panelLoader.item.hasOwnProperty("activeTabIndex") + } + } + } +} diff --git a/modules/nexus/Nexus.qml b/modules/nexus/Nexus.qml new file mode 100644 index 000000000..17f391365 --- /dev/null +++ b/modules/nexus/Nexus.qml @@ -0,0 +1,247 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Caelestia.Blobs +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus +import qs.modules.nexus.components + +Item { + id: root + + required property ShellScreen screen + + readonly property int rounding: floating ? 0 : Tokens.rounding.normal + readonly property int borderPad: 50 + + property bool floating: false + property alias active: session.activeCategory + property alias sidebarCollapsed: session.sidebarCollapsed + + readonly property NexusSession session: NexusSession { + id: session + + nexusRoot: root + } + + readonly property bool flyoutOverlapsPopout: flyout.open && unifiedPopout.open && flyout.y < unifiedPopout.y + unifiedPopout.drawerHeight && flyout.y + flyout.drawerHeight > unifiedPopout.y + + signal close + + implicitWidth: implicitHeight * 1.67 + implicitHeight: Math.min(1000, screen.height * 0.85) + + ContentArea { + anchors.left: sidebar.right + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + + clip: true + session: root.session + } + + Item { + id: blobLayer + + anchors.fill: parent + opacity: Colours.tPalette.m3surfaceContainer.a + layer.enabled: true // So children don't opacity stack + + Behavior on opacity { + Anim {} + } + + BlobGroup { + id: blobGroup + + color: Qt.alpha(Colours.tPalette.m3surfaceContainer, 1) + smoothing: 28 + + Behavior on color { + CAnim {} + } + } + + // Border frame + BlobInvertedRect { + anchors.fill: parent + anchors.margins: -root.borderPad + group: blobGroup + radius: Tokens.rounding.small + borderLeft: sidebar.width + 10 + root.borderPad + borderTop: 10 + root.borderPad + borderRight: 10 + root.borderPad + borderBottom: 10 + root.borderPad + } + + BlobRect { + id: notchBlob + + anchors.right: parent.right + group: blobGroup + implicitWidth: windowControls.width + windowControls.anchors.rightMargin * 2 + implicitHeight: windowControls.height + windowControls.anchors.topMargin * 2 + bottomLeftRadius: Tokens.rounding.normal + deformScale: 0 + } + + BlobRect { + id: flyoutBlob + + group: blobGroup + x: flyout.x + y: flyout.y + implicitWidth: flyout.drawerWidth + implicitHeight: flyout.drawerHeight + radius: Tokens.rounding.small + // topLeftRadius: 0 + // bottomLeftRadius: 0 + // topRightRadius: flyout.y <= 0 ? 0 : Tokens.rounding.small + deformScale: 0.00001 + stiffness: 200 + damping: 16 + } + + BlobRect { + id: popoutBlob + + group: blobGroup + x: unifiedPopout.x + y: unifiedPopout.y + implicitWidth: unifiedPopout.drawerWidth + implicitHeight: unifiedPopout.drawerHeight + visible: session.sidebarCollapsed + radius: Tokens.rounding.normal + // topLeftRadius: 0 + // topRightRadius: 0 + // bottomLeftRadius: 0 + deformScale: 0.00001 + stiffness: 200 + damping: 16 + } + } + + Sidebar { + id: sidebar + + anchors.top: parent.top + anchors.bottom: parent.bottom + implicitWidth: session.sidebarCollapsed ? 100 : 250 + + session: root.session + + Behavior on implicitWidth { + Anim { + type: Anim.DefaultSpatial + } + } + } + + RowLayout { + id: windowControls + + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: Tokens.padding.smaller + anchors.rightMargin: Tokens.padding.normal + spacing: 0 + + IconButton { + type: IconButton.Text + icon: root.floating ? "pip" : "pip_exit" // Yes, I know this looks reversed but it really isn't + label.fill: 0 + inactiveOnColour: Colours.palette.m3onSurfaceVariant + onClicked: { + Hyprland.dispatch("togglefloating"); + root.floating = !root.floating; + } + } + + IconButton { + type: IconButton.Text + icon: "close" + inactiveOnColour: Colours.palette.m3onSurfaceVariant + onClicked: root.close() + } + } + + SidebarFlyout { + id: flyout + + session: root.session + flyoutCategory: sidebar.flyoutCategory + open: session.sidebarCollapsed && sidebar.flyoutCategory !== "" + + x: sidebar.width + y: sidebar.flyoutTop + + onHoverEntered: sidebar.cancelFlyoutClose() + onHoverExited: sidebar.scheduleFlyoutClose() + onChildClicked: id => session.setCategory(id) + + Behavior on y { + enabled: flyout.open + + Anim { + type: Anim.DefaultSpatial + } + } + } + + Component { + id: searchComponent + + SearchEngine { + session: root.session // qmllint disable incompatible-type + } + } + + Component { + id: configComponent + + ConfigSwitcher { + session: root.session // qmllint disable incompatible-type + } + } + + SidebarPopout { + id: unifiedPopout + + x: sidebar.width + y: 0 + visible: session.sidebarCollapsed + touchingTop: true + extraLeftMargin: root.flyoutOverlapsPopout ? flyout.drawerWidth : 0 + flyoutDrawerWidth: flyout.drawerWidth + flyoutOpen: flyout.open + + open: session.searchPopoutOpen || session.configPopoutOpen + popoutType: session.searchPopoutOpen ? "search" : session.configPopoutOpen ? "config" : "" + popoutWidth: popoutType === "search" ? 280 : 275 + + Component.onCompleted: { + setComponents(searchComponent, configComponent); + } + } + + MouseArea { + x: sidebar.width + y: 0 + width: parent.width - sidebar.width + height: parent.height + z: -1 + visible: session.sidebarCollapsed && (session.searchPopoutOpen || session.configPopoutOpen) + + onClicked: { + session.searchPopoutOpen = false; + session.configPopoutOpen = false; + } + } +} diff --git a/modules/nexus/NexusRegistry.qml b/modules/nexus/NexusRegistry.qml new file mode 100644 index 000000000..81fa3f1cc --- /dev/null +++ b/modules/nexus/NexusRegistry.qml @@ -0,0 +1,569 @@ +pragma Singleton + +import QtQuick +import Quickshell.Services.UPower + +QtObject { + id: root + + readonly property int count: getCategories().length + + readonly property var settingDefinitions: [ + // Appearance + { + label: "Theme Mode", + category: "appearance", + tab: "Wallpaper & Scheme", + keywords: ["theme", "dark", "light", "auto", "mode"] + }, + { + label: "Color Scheme", + category: "appearance", + tab: "Wallpaper & Scheme", + keywords: ["color", "scheme", "catppuccin", "everforest", "nord", "gruvbox"] + }, + { + label: "Wallpaper", + category: "appearance", + tab: "Wallpaper & Scheme", + keywords: ["wallpaper", "background", "desktop", "image"] + }, + { + label: "UI Font", + category: "appearance", + tab: "Typography & Motion", + keywords: ["font", "typography", "text", "roboto"] + }, + { + label: "Animation Speed", + category: "appearance", + tab: "Typography & Motion", + keywords: ["animation", "speed", "motion"] + }, + { + label: "Window Shadows", + category: "appearance", + tab: "Effects", + keywords: ["shadow", "window", "effect"] + }, + { + label: "Corner Rounding", + category: "appearance", + tab: "Effects", + keywords: ["corner", "rounding", "radius"] + }, + + // Taskbar + { + label: "Taskbar Auto-hide", + category: "taskbar", + tab: "General", + keywords: ["taskbar", "auto", "hide", "autohide"] + }, + { + label: "Workspace Indicator", + category: "taskbar", + tab: "Workspaces", + keywords: ["workspace", "indicator", "taskbar"] + }, + { + label: "Status Icons", + category: "taskbar", + tab: "Systray & Status", + keywords: ["status", "icons", "tray", "system"] + }, + + // Launcher + { + label: "Launcher Enabled", + category: "launcher", + tab: "General", + keywords: ["launcher", "enabled", "toggle"] + }, + { + label: "Launcher Apps", + category: "launcher", + tab: "General", + keywords: ["launcher", "apps", "applications"] + }, + { + label: "Favorite Apps", + category: "launcher", + tab: "Applications", + keywords: ["favorite", "apps", "pin", "starred"] + }, + { + label: "Actions", + category: "launcher", + tab: "Actions", + keywords: ["actions", "special", "commands", "calculator"] + }, + + // Dashboard + { + label: "Widget Management", + category: "dashboard", + tab: "Dashboard", + keywords: ["widget", "manage", "dashboard"] + }, + { + label: "Media Widget", + category: "dashboard", + tab: "Media", + keywords: ["media", "player", "music", "album"] + }, + { + label: "System Monitor", + category: "dashboard", + tab: "Performance", + keywords: ["system", "monitor", "cpu", "ram"] + }, + { + label: "Weather Widget", + category: "dashboard", + tab: "Weather", + keywords: ["weather", "temperature", "forecast"] + }, + + // Display + { + label: "Display Resolution", + category: "display", + tab: "General", + keywords: ["display", "monitor", "resolution", "refresh"] + }, + { + label: "Night Light", + category: "display", + tab: "Night Light", + keywords: ["night", "light", "blue", "eye", "temperature"] + }, + + // Network + { + label: "Wi-Fi Settings", + category: "network", + tab: "Wireless", + keywords: ["wifi", "wireless", "network", "connect"] + }, + { + label: "Ethernet Settings", + category: "network", + tab: "Ethernet", + keywords: ["ethernet", "wired", "lan"] + }, + { + label: "VPN Connections", + category: "network", + tab: "VPN", + keywords: ["vpn", "wireguard", "tunnel", "privacy"] + }, + + // Audio + { + label: "Audio Output", + category: "audio", + tab: "Output & Input", + keywords: ["audio", "output", "speaker", "headphone", "volume"] + }, + { + label: "Microphone", + category: "audio", + tab: "Output & Input", + keywords: ["audio", "input", "microphone", "mic"] + }, + { + label: "Per-App Volume", + category: "audio", + tab: "Applications", + keywords: ["application", "volume", "per", "app", "mixer"] + }, + + // Bluetooth + { + label: "Bluetooth Devices", + category: "bluetooth", + tab: "Devices", + keywords: ["bluetooth", "device", "pair", "connect"] + }, + { + label: "Bluetooth Settings", + category: "bluetooth", + tab: "Settings", + keywords: ["bluetooth", "discoverable", "codec"] + }, + + // Power + { + label: "Power Mode", + category: "power", + tab: "Inhibit and idle", + keywords: ["inhibit", "idle", "timeout", "sleep", "suspend", "lock"] + }, + { + label: "Battery Behavior", + category: "power", + tab: "Battery Behavior", + keywords: ["battery", "charge", "health", "cycle"] + }, + + // Notifications + { + label: "Notification Settings", + category: "notifications", + tab: "General", + keywords: ["notification", "alert", "badge", "sound"] + }, + { + label: "Per-App Notifications", + category: "notifications", + tab: "Applications", + keywords: ["notification", "application", "per", "app"] + }, + { + label: "Do Not Disturb", + category: "notifications", + tab: "On-Screen-Display", + keywords: ["do", "not", "disturb", "dnd", "quiet"] + }, + + // Plugins + { + label: "Plugin Management", + category: "plugins", + tab: "General", + keywords: ["plugin", "extension", "addon", "manage"] + }, + { + label: "Launcher Plugins", + category: "plugins", + tab: "Launcher", + keywords: ["launcher", "plugin", "extension"] + }, + { + label: "Taskbar Plugins", + category: "plugins", + tab: "Taskbar", + keywords: ["taskbar", "plugin", "extension"] + } + ] + + function getCategories() { + return [ + { + id: "appearance", + label: "Appearance", + icon: "palette", + isDirect: true, + tabs: ["Wallpaper & Scheme", "Typography & Motion", "Effects"], + title: "Appearance", + description: "Customize the look and feel of your desktop", + children: [] + }, + { + id: "shell", + label: "Shell", + icon: "desktop_windows", + isDirect: false, + tabs: [], + title: "", + description: "", + children: [ + { + id: "taskbar", + label: "Taskbar", + icon: "dock_to_bottom", + tabs: ["General", "Workspaces", "Systray & Status"], + title: "Taskbar", + description: "Configure your taskbar appearance and behavior" + }, + { + id: "launcher", + label: "Launcher", + icon: "apps", + tabs: ["General", "Applications", "Actions"], + title: "Launcher", + description: "Customize application launcher settings" + }, + { + id: "dashboard", + label: "Dashboard", + icon: "dashboard", + tabs: ["Dashboard", "Media", "Performance", "Weather"], + title: "Dashboard", + description: "Configure dashboard widgets and layout" + }, + { + id: "sidebar", + label: "Sidebar", + icon: "side_navigation", + tabs: [], + title: "Sidebar", + description: "Sidebar panel settings" + }, + { + id: "utilities", + label: "Utilities", + icon: "handyman", + tabs: [], + title: "Utilities", + description: "Quick access utilities and tools" + }, + { + id: "notifications", + label: "Notifications", + icon: "notifications", + tabs: ["General", "Applications", "On-Screen-Display"], + title: "Notifications", + description: "Manage notification settings and behavior" + }, + { + id: "session", + label: "Session", + icon: "account_circle", + tabs: [], + title: "Session Menus", + description: "User session and power menu settings" + }, + { + id: "lockscreen", + label: "Lockscreen", + icon: "lock", + tabs: [], + title: "Lockscreen", + description: "Lock screen appearance and security" + } + ] + }, + { + id: "display", + label: "Display", + icon: "monitor", + isDirect: true, + tabs: ["General", "Night Light"], + title: "Display", + description: "Monitor configuration and display settings", + children: [] + }, + { + id: "services", + label: "Services", + icon: "settings_applications", + isDirect: false, + tabs: [], + title: "", + description: "", + children: [ + { + id: "network", + label: "Network", + icon: "wifi", + tabs: ["Wireless", "Ethernet", "VPN"], + title: "Network", + description: "Network connections and settings" + }, + { + id: "audio", + label: "Audio", + icon: "volume_up", + tabs: ["Output & Input", "Applications"], + title: "Audio", + description: "Sound devices and volume control" + }, + { + id: "bluetooth", + label: "Bluetooth", + icon: "bluetooth", + tabs: ["Devices", "Settings"], + title: "Bluetooth", + description: "Bluetooth device management" + }, + { + id: "location", + label: "Location", + icon: "location_on", + tabs: [], + title: "Location Services", + description: "Location access and privacy" + }, + { + id: "screenrecorder", + label: "Screen Recorder", + icon: "screen_record", + tabs: [], + title: "Screen Recorder", + description: "Screen recording settings" + } + ] + }, + { + id: "power", + label: "Power", + icon: "power_settings_new", + isDirect: true, + tabs: ["Inhibit and idle", "Battery Behavior"], + title: "Power", + description: "Power management and battery settings", + children: [] + }, + { + id: "advanced", + label: "Advanced", + icon: "tune", + isDirect: false, + tabs: [], + title: "", + description: "", + children: [ + { + id: "plugins", + label: "Plugins", + icon: "extension", + tabs: ["General", "Launcher", "Taskbar", "Dashboard"], + title: "Plugins", + description: "Manage and configure plugins" + }, + { + id: "hooks", + label: "Hooks", + icon: "link", + tabs: [], + title: "Hooks", + description: "System hooks and automation" + } + ] + } + ]; + } + + function getBottomItems() { + return [ + { + id: "about", + label: "About", + icon: "info", + isDirect: true, + tabs: [], + title: "About Caelestia", + description: "System information and credits", + children: [] + }, + { + id: "updates", + label: "Updates", + icon: "update", + isDirect: true, + tabs: [], + title: "Updates", + description: "System updates and changelog", + children: [] + } + ]; + } + + function getByIndex(index) { + const cats = getCategories(); + if (index >= 0 && index < cats.length) + return cats[index]; + return null; + } + + function getById(id) { + const cats = getCategories(); + for (let i = 0; i < cats.length; i++) { + if (cats[i].id === id) + return cats[i]; + const children = cats[i].children; + for (let j = 0; j < children.length; j++) { + if (children[j].id === id) + return children[j]; + } + } + const bottom = getBottomItems(); + for (let i = 0; i < bottom.length; i++) { + if (bottom[i].id === id) + return bottom[i]; + } + return null; + } + + function getCategoryTabs(id) { + const tabs = getById(id)?.tabs ?? []; + if (id === "power" && !(UPower.displayDevice?.isLaptopBattery ?? false)) + return tabs.filter(t => t !== "Battery Behavior"); + return tabs; + } + + function isChildActive(parentId, activeId) { + const parent = getById(parentId); + if (!parent || parent.isDirect) + return false; + for (let i = 0; i < parent.children.length; i++) { + if (parent.children[i].id === activeId) + return true; + } + return false; + } + + function buildSearchIndex() { + return root.settingDefinitions.map(setting => { + const cat = getById(setting.category); + const categoryLabel = cat?.label ?? setting.category; + const tab = setting.tab || (cat?.tabs?.[0] ?? ""); + const icon = cat?.icon ?? "settings"; + return { + label: setting.label, + categoryId: setting.category, + tab: tab, + categoryLabel: categoryLabel, + icon: icon, + keywords: setting.keywords, + description: setting.description || "" + }; + }); + } + + function calculateScore(item, query, terms) { + let score = 0; + const labelLower = item.label.toLowerCase(); + const catLower = item.categoryLabel.toLowerCase(); + + // Exact label match gets highest score + if (labelLower === query) + score += 15; + else if (labelLower.includes(query)) + score += 10; + + for (const term of terms) { + if (labelLower.includes(term)) + score += 5; + if (catLower.includes(term)) + score += 2; + + for (const kw of item.keywords) { + if (kw === term) + score += 4; + else if (kw.startsWith(term)) + score += 3; + else if (kw.includes(term)) + score += 1; + } + } + + return score; + } + + function searchSettings(query, maxResults) { + if (!query || !query.trim()) + return []; + maxResults = maxResults || 8; + + const lower = query.toLowerCase().trim(); + const terms = lower.split(/\s+/); + const index = root.buildSearchIndex(); + + return index.map(item => Object.assign({}, item, { + score: root.calculateScore(item, lower, terms) + })).filter(item => item.score > 0).sort((a, b) => b.score - a.score).slice(0, maxResults); + } +} diff --git a/modules/nexus/NexusSession.qml b/modules/nexus/NexusSession.qml new file mode 100644 index 000000000..d41f7c2a2 --- /dev/null +++ b/modules/nexus/NexusSession.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.modules.nexus + +QtObject { + id: root + + required property var nexusRoot + + property string activeCategory: "appearance" + property bool sidebarCollapsed: true + property string expandedCategory: "" + property string flyoutCategory: "" + property string searchQuery: "" + property string forcedTab: "" + property string activeConfig: "global" + property bool searchPopoutOpen: false + property bool configPopoutOpen: false + + readonly property var activeCategoryConfig: NexusRegistry.getById(activeCategory) + property string _savedExpandedCategory: "" + + function setCategory(id) { + activeCategory = id; + forcedTab = ""; + } + + function setSearchNavigate(category, tab) { + activeCategory = category; + forcedTab = tab; + searchQuery = ""; + } + + function consumeForcedTab() { + const tab = forcedTab; + forcedTab = ""; + return tab; + } + + function toggleSidebar() { + if (!sidebarCollapsed) { + _savedExpandedCategory = expandedCategory; + expandedCategory = ""; + sidebarCollapsed = true; + } else { + sidebarCollapsed = false; + flyoutCategory = ""; + searchPopoutOpen = false; + configPopoutOpen = false; + expandedCategory = _savedExpandedCategory; + } + } +} diff --git a/modules/nexus/NexusWindowFactory.qml b/modules/nexus/NexusWindowFactory.qml new file mode 100644 index 000000000..44f80e7ff --- /dev/null +++ b/modules/nexus/NexusWindowFactory.qml @@ -0,0 +1,65 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Hyprland +import qs.components +import qs.services +import qs.modules.nexus + +Singleton { + id: root + + function create(parent, props) { + nexusWindow.createObject(parent ?? dummy, props); + } + + QtObject { + id: dummy + } + + Component { + id: nexusWindow + + FloatingWindow { + id: win + + property alias active: nexus.active + property alias sidebarCollapsed: nexus.sidebarCollapsed + + color: Colours.tPalette.m3surface + + onVisibleChanged: { + if (!visible) + destroy(); + } + + implicitWidth: nexus.implicitWidth + implicitHeight: nexus.implicitHeight + + minimumSize.width: 640 + minimumSize.height: 400 + + title: qsTr("Nexus - %1").arg(nexus.active.slice(0, 1).toUpperCase() + nexus.active.slice(1)) + + Nexus { + id: nexus + + anchors.fill: parent + screen: win.screen + onClose: win.destroy() + floating: { + const our = Hyprland.toplevels.values.find(t => t.title === win.title); + if (our?.lastIpcObject) + return !!our.lastIpcObject.floating; + const active = Hyprland.activeToplevel?.lastIpcObject; + return !(active?.floating ?? true); + } + } + + Behavior on color { + CAnim {} + } + } + } +} diff --git a/modules/nexus/Sidebar.qml b/modules/nexus/Sidebar.qml new file mode 100644 index 000000000..62861ab75 --- /dev/null +++ b/modules/nexus/Sidebar.qml @@ -0,0 +1,189 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus +import qs.modules.nexus.components + +Item { + id: root + + required property NexusSession session + + property string flyoutCategory: "" + property real flyoutTop: 0 + property string _pendingCategory: "" + + function openFlyout(categoryId, itemGlobalY) { + flyoutCloseTimer.stop(); + + const cat = NexusRegistry.getById(categoryId); + const childCount = cat && cat.children ? cat.children.length : 0; + const flyoutHeight = childCount * 68 + 36; + let top = itemGlobalY - flyoutHeight / 2 + 20; + if (top < 10) + top = 10; + + root.flyoutTop = top; + _pendingCategory = categoryId; + openDelayTimer.start(); + } + function scheduleFlyoutClose() { + flyoutCloseTimer.restart(); + } + + function cancelFlyoutClose() { + flyoutCloseTimer.stop(); + } + + Timer { + id: openDelayTimer + + interval: 50 + onTriggered: { + root.flyoutCategory = root._pendingCategory; + root._pendingCategory = ""; + } + } + + Timer { + id: flyoutCloseTimer + + interval: 250 + onTriggered: root.flyoutCategory = "" + } + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small + anchors.rightMargin: Tokens.padding.small + anchors.topMargin: Tokens.padding.large + anchors.bottomMargin: Tokens.padding.smaller + spacing: 0 + + SidebarHeader { + z: 10 + Layout.fillWidth: true + Layout.leftMargin: Tokens.padding.normal + session: root.session // qmllint disable incompatible-type + } + + Flickable { + id: navFlick + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: Tokens.spacing.normal + clip: true + contentHeight: navColumn.height + boundsBehavior: Flickable.StopAtBounds + + Column { + id: navColumn + + width: navFlick.width + spacing: Tokens.spacing.small + + Repeater { + model: NexusRegistry.getCategories() + + delegate: Column { + id: catDelegate + + required property var modelData + required property int index + + readonly property string catId: modelData.id + readonly property bool hasChildren: modelData.children && modelData.children.length > 0 + + width: navColumn.width + + SidebarNavItem { + session: root.session // qmllint disable incompatible-type + modelData: catDelegate.modelData + flyoutActive: root.flyoutCategory === catDelegate.catId + onFlyoutRequested: function (itemY) { + root.openFlyout(catDelegate.catId, itemY); + } + onFlyoutCloseRequested: root.scheduleFlyoutClose() + } + + SidebarAccordion { + visible: !root.session.sidebarCollapsed && catDelegate.hasChildren + session: root.session // qmllint disable incompatible-type + childItems: catDelegate.hasChildren ? catDelegate.modelData.children : [] + open: root.session.expandedCategory === catDelegate.catId + } + } + } + } + } + + // Spacer + Item { + Layout.fillHeight: false + Layout.preferredHeight: Tokens.spacing.large + } + + // Bottom items + Column { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + Repeater { + model: NexusRegistry.getBottomItems() + + delegate: SidebarBottomItem { + session: root.session // qmllint disable incompatible-type + } + } + } + + // Separator above collapse toggle + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + Layout.bottomMargin: Tokens.spacing.normal + Layout.topMargin: Tokens.spacing.normal + Layout.leftMargin: Tokens.padding.large + color: Qt.alpha(Colours.palette.m3onSurface, 0.08) + } + + // Collapse toggle + Item { + Layout.fillWidth: true + Layout.leftMargin: Tokens.padding.large / 1.5 + Layout.preferredHeight: 48 + + StyledRect { + width: parent.width + height: 40 + radius: Tokens.rounding.full + color: "transparent" + + Behavior on width { + Anim { + type: Anim.DefaultSpatial + } + } + + StateLayer { + color: Colours.palette.m3onSurface + onClicked: root.session.toggleSidebar() + } + + MaterialIcon { + anchors.centerIn: parent + text: root.session.sidebarCollapsed ? "keyboard_double_arrow_right" : "keyboard_double_arrow_left" + color: Colours.palette.m3onSurface + font.pointSize: Tokens.font.size.large + } + } + } + } +} diff --git a/modules/nexus/SidebarFlyout.qml b/modules/nexus/SidebarFlyout.qml new file mode 100644 index 000000000..1bfcc21db --- /dev/null +++ b/modules/nexus/SidebarFlyout.qml @@ -0,0 +1,195 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus + +Item { + id: root + + required property NexusSession session + + property string flyoutCategory: "" + property bool open: false + readonly property var currentCat: flyoutCategory !== "" ? NexusRegistry.getById(flyoutCategory) : null + readonly property int childCount: currentCat?.children?.length ?? 0 + readonly property real drawerWidth: drawer.width + readonly property real drawerHeight: drawer.height + + property string _prevCategory: "" + property var _prevCat: null + + signal hoverEntered + signal hoverExited + signal childClicked(string id) + + implicitWidth: drawer.targetWidth + 2 + implicitHeight: drawer.targetHeight + + onFlyoutCategoryChanged: { + if (flyoutCategory === "") { + _prevCategory = ""; + contentFadeOut.start(); + } else if (_prevCategory === "") { + _prevCategory = flyoutCategory; + _prevCat = NexusRegistry.getById(flyoutCategory); + contentFadeOut.stop(); + contentContainer.opacity = 0; + contentFadeIn.restart(); + } else { + contentFadeOut.start(); + } + } + + Rectangle { + id: drawer + + property real targetWidth: 100 + property real targetHeight: (root.currentCat?.children?.length ?? 0) * 68 + 46 || 80 + + width: root.open ? targetWidth : 0 + height: root.open ? targetHeight : drawer.height + clip: true + color: "transparent" + radius: 0 + + Behavior on width { + Anim { + type: Anim.DefaultSpatial + } + } + + Behavior on height { + Anim { + type: Anim.DefaultSpatial + } + } + + HoverHandler { + onHoveredChanged: { + if (hovered) + root.hoverEntered(); + else + root.hoverExited(); + } + } + + Item { + id: contentContainer + + anchors.fill: parent + anchors.margins: 12 + opacity: 1 + + NumberAnimation { + id: contentFadeOut + + target: contentContainer + property: "opacity" + from: 1 + to: 0 + duration: 120 + onFinished: { + root._prevCategory = root.flyoutCategory; + root._prevCat = root.currentCat; + contentFadeIn.start(); + } + } + + NumberAnimation { + id: contentFadeIn + + target: contentContainer + property: "opacity" + from: 0 + to: 1 + duration: 250 + } + + // Category label + StyledText { + id: flyoutLabel + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + text: root._prevCat?.label ?? "" + color: Qt.alpha(Colours.palette.m3onSurface, 0.35) + font.pointSize: Tokens.font.size.small - 1 + font.capitalization: Font.AllUppercase + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + } + + Column { + id: childColumn + + anchors.top: flyoutLabel.bottom + anchors.topMargin: 6 + anchors.left: parent.left + anchors.right: parent.right + spacing: 6 + + Repeater { + model: root._prevCat?.children ?? [] + + delegate: Item { + id: flyoutChild + + required property var modelData + + readonly property bool isActive: root.session.activeCategory === flyoutChild.modelData.id + + width: childColumn.width + height: 64 + + Rectangle { + anchors.fill: parent + radius: Tokens.rounding.normal + color: flyoutChild.isActive ? Qt.alpha(Colours.palette.m3primary, 0.16) : "transparent" + + Behavior on color { + CAnim {} + } + + StateLayer { + radius: Tokens.rounding.normal + color: flyoutChild.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + onClicked: root.childClicked(flyoutChild.modelData.id) + } + + Column { + anchors.centerIn: parent + spacing: 3 + + MaterialIcon { + anchors.horizontalCenter: parent.horizontalCenter + text: flyoutChild.modelData.icon + color: flyoutChild.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + font.pointSize: Tokens.font.size.larger + fill: flyoutChild.isActive ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: flyoutChild.modelData.label.length > 12 ? flyoutChild.modelData.label.substring(0, 11) + "…" : flyoutChild.modelData.label + color: flyoutChild.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + font.pointSize: Tokens.font.size.small - 1 + font.capitalization: Font.Capitalize + font.weight: Font.Medium + } + } + } + } + } + } + } + } +} diff --git a/modules/nexus/components/ConfigSwitcher.qml b/modules/nexus/components/ConfigSwitcher.qml new file mode 100644 index 000000000..1ee779655 --- /dev/null +++ b/modules/nexus/components/ConfigSwitcher.qml @@ -0,0 +1,132 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus + +ColumnLayout { + id: root + + required property NexusSession session + + readonly property var configModel: { + const items = [ + { + id: "global", + label: "Global", + icon: "language", + desc: "Settings apply everywhere" + } + ]; + for (const screen of Screens.screens) { + items.push({ + id: screen.name, + label: screen.name, + icon: "monitor", + desc: "Monitor-specific overrides" + }); + } + return items; + } + + spacing: Tokens.spacing.small + + StyledText { + text: "Editing Context" + font.pointSize: Tokens.font.size.small + font.weight: Font.DemiBold + font.capitalization: Font.AllUppercase + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + + // Divider + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.1) + } + + Repeater { + model: root.configModel + + delegate: Item { + id: configDelegate + + required property var modelData + + Layout.fillWidth: true + Layout.preferredHeight: 48 + + readonly property bool isActive: root.session.activeConfig === modelData.id + + StyledRect { + anchors.fill: parent + radius: Tokens.rounding.normal + color: configDelegate.isActive ? Qt.alpha(Colours.palette.m3primary, 0.12) : "transparent" + + Behavior on color { + CAnim {} + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Tokens.spacing.normal + anchors.rightMargin: Tokens.spacing.normal + spacing: Tokens.spacing.normal + + MaterialIcon { + text: configDelegate.modelData.icon + font.pointSize: Tokens.font.size.larger + color: configDelegate.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + fill: configDelegate.isActive ? 1 : 0 + + Behavior on color { + CAnim {} + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + text: configDelegate.modelData.label + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: configDelegate.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + + Behavior on color { + CAnim {} + } + } + + StyledText { + text: configDelegate.modelData.desc + font.pointSize: Tokens.font.size.small - 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + } + + MaterialIcon { + visible: configDelegate.isActive + text: "check" + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3primary + } + } + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + root.session.activeConfig = configDelegate.modelData.id; + root.session.configPopoutOpen = false; + } + } + } + } + } +} diff --git a/modules/nexus/components/SearchEngine.qml b/modules/nexus/components/SearchEngine.qml new file mode 100644 index 000000000..0f42e56a1 --- /dev/null +++ b/modules/nexus/components/SearchEngine.qml @@ -0,0 +1,228 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus + +ColumnLayout { + id: root + + required property NexusSession session + + spacing: Tokens.spacing.normal + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 44 + + StyledRect { + anchors.fill: parent + radius: Tokens.rounding.full + color: Qt.alpha(Colours.palette.m3surfaceContainerHighest, 0.6) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Tokens.spacing.normal + anchors.rightMargin: Tokens.spacing.normal + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "search" + font.pointSize: Tokens.font.size.larger + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + + TextField { + id: searchField + + Layout.fillWidth: true + + placeholderText: "Search settings..." + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurface + background: Item {} + + onTextChanged: { + root.session.searchQuery = text; + } + + Component.onCompleted: { + searchField.text = root.session.searchQuery; + searchField.forceActiveFocus(); + } + } + + MaterialIcon { + visible: searchField.text.length > 0 + text: "close" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + + StateLayer { + radius: Tokens.rounding.full + color: Colours.palette.m3onSurface + onClicked: { + searchField.text = ""; + root.session.searchQuery = ""; + searchField.forceActiveFocus(); + } + } + } + } + } + } + + // Divider + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.1) + } + + // Search results + Flickable { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(resultsColumn.height, 300) + clip: true + contentHeight: resultsColumn.height + boundsBehavior: Flickable.StopAtBounds + visible: root.session.searchQuery.length > 0 && NexusRegistry.searchSettings(root.session.searchQuery).length > 0 // qmllint disable missing-property + + Column { + id: resultsColumn + + width: parent.width + spacing: 0 + + Repeater { + id: resultsRepeater + + model: NexusRegistry.searchSettings(root.session.searchQuery) // qmllint disable missing-property + + delegate: Item { + id: resultDelegate + + required property var modelData + + width: parent.width + height: 56 + + StateLayer { + radius: Tokens.rounding.normal + color: Colours.palette.m3onSurface + onClicked: { + root.session.setSearchNavigate(resultDelegate.modelData.categoryId, resultDelegate.modelData.tab || ""); + root.session.searchPopoutOpen = false; + } + } + + Row { + anchors.fill: parent + anchors.leftMargin: Tokens.spacing.normal + anchors.rightMargin: Tokens.spacing.normal + spacing: Tokens.spacing.normal + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 18 + height: 18 + radius: 5 + color: Qt.alpha(Colours.palette.m3primary, 0.1) + + MaterialIcon { + anchors.centerIn: parent + text: "arrow_forward" + font.pointSize: Tokens.font.size.small - 1 + color: Colours.palette.m3primary + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - parent.spacing - 32 + + StyledText { + text: resultDelegate.modelData.label + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3onSurface + } + + StyledText { + text: resultDelegate.modelData.categoryLabel + (resultDelegate.modelData.tab ? " › " + resultDelegate.modelData.tab : "") + font.pointSize: Tokens.font.size.small - 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + } + } + } + } + } + } + + // Empty state + Item { + visible: root.session.searchQuery.length === 0 + Layout.fillWidth: true + Layout.preferredHeight: 100 + + MaterialIcon { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 10 + text: "search" + font.pointSize: Tokens.font.size.larger * 2 + color: Qt.alpha(Colours.palette.m3onSurface, 0.15) + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 50 + text: "Type to search settings" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.35) + } + } + + // No results + Item { + visible: root.session.searchQuery.length > 0 && NexusRegistry.searchSettings(root.session.searchQuery).length === 0 // qmllint disable missing-property + Layout.fillWidth: true + Layout.preferredHeight: 100 + + MaterialIcon { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 10 + text: "sentiment_dissatisfied" + font.pointSize: Tokens.font.size.larger * 2 + color: Qt.alpha(Colours.palette.m3onSurface, 0.15) + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 50 + text: "No results for \"" + root.session.searchQuery + "\"" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.35) + } + } + + // Clear search when popout closes + Connections { + function onSearchPopoutOpenChanged() { + if (!root.session.searchPopoutOpen) { + searchField.text = ""; + root.session.searchQuery = ""; + } + } + + target: root.session + } +} diff --git a/modules/nexus/components/SidebarAccordion.qml b/modules/nexus/components/SidebarAccordion.qml new file mode 100644 index 000000000..be6a498e1 --- /dev/null +++ b/modules/nexus/components/SidebarAccordion.qml @@ -0,0 +1,105 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus + +Item { + id: root + + required property NexusSession session + required property var childItems + required property bool open + + width: parent ? parent.width + 2 : 0 + height: open ? col.implicitHeight : 0 + clip: true + + Behavior on height { + Anim { + type: Anim.DefaultSpatial + } + } + + // Vertical line indicator + Rectangle { + x: Tokens.padding.large + 16 + y: 0 + width: 1 + height: root.open ? col.height : 0 + color: Qt.alpha(Colours.palette.m3onSurface, 0.12) + + Behavior on height { + Anim { + type: Anim.DefaultSpatial + } + } + } + + Column { + id: col + + width: parent.width - (Tokens.padding.large / 2) + topPadding: Tokens.spacing.small + leftPadding: Tokens.padding.large + spacing: Tokens.spacing.small + + Repeater { + model: root.childItems + + delegate: Item { + id: childDelegate + + required property var modelData + + readonly property bool isActive: root.session.activeCategory === childDelegate.modelData.id + + width: col.width + height: 36 + + StyledRect { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.larger * 2 + anchors.rightMargin: Tokens.padding.normal + + radius: Tokens.rounding.full + color: childDelegate.isActive ? Qt.alpha(Colours.palette.m3primary, 0.16) : "transparent" + + Behavior on color { + CAnim {} + } + + StateLayer { + color: childDelegate.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + onClicked: root.session.setCategory(childDelegate.modelData.id) + } + + Row { + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + spacing: Tokens.spacing.normal + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + text: childDelegate.modelData.icon + color: childDelegate.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + font.pointSize: Tokens.font.size.normal + fill: childDelegate.isActive ? 1 : 0 + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: childDelegate.modelData.label + color: childDelegate.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + font.pointSize: Tokens.font.size.smaller + font.capitalization: Font.Capitalize + } + } + } + } + } + } +} diff --git a/modules/nexus/components/SidebarBottomItem.qml b/modules/nexus/components/SidebarBottomItem.qml new file mode 100644 index 000000000..38b323032 --- /dev/null +++ b/modules/nexus/components/SidebarBottomItem.qml @@ -0,0 +1,99 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus + +Item { + id: root + + required property NexusSession session + required property var modelData + + readonly property bool isActive: session.activeCategory === modelData.id + readonly property bool collapsed: session.sidebarCollapsed + + width: parent ? parent.width : 0 + height: collapsed ? 68 : 40 + + Behavior on height { + Anim { + type: Anim.DefaultSpatial + } + } + + StyledRect { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.normal + + radius: root.collapsed ? Tokens.rounding.normal : Tokens.rounding.full + color: root.isActive ? Qt.alpha(Colours.palette.m3secondaryContainer, 1) : "transparent" + + Behavior on radius { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on color { + CAnim {} + } + + StateLayer { + color: root.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + onClicked: root.session.setCategory(root.modelData.id) + } + + MaterialIcon { + id: btmIcon + + x: root.collapsed ? (parent.width - width) / 2 : Tokens.padding.large + y: root.collapsed ? (parent.height - height) / 2 - 10 : (parent.height - height) / 2 + + text: root.modelData.icon + color: root.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: root.collapsed ? Tokens.font.size.large + 2 : Tokens.font.size.larger + fill: root.isActive ? 1 : 0 + + Behavior on x { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on font.pointSize { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on fill { + Anim { + type: Anim.DefaultSpatial + } + } + } + + StyledText { + x: root.collapsed ? (parent.width - width) / 2 : btmIcon.x + btmIcon.width + Tokens.spacing.normal + y: root.collapsed ? parent.height - height - 6 : (parent.height - height) / 2 + + text: root.modelData.label + color: root.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: root.collapsed ? Tokens.font.size.small - 1 : Tokens.font.size.normal + font.capitalization: Font.Capitalize + + opacity: root.collapsed ? 0.8 : 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on font.pointSize { + Anim { + type: Anim.DefaultSpatial + } + } + } + } +} diff --git a/modules/nexus/components/SidebarHeader.qml b/modules/nexus/components/SidebarHeader.qml new file mode 100644 index 000000000..44cbb5c0e --- /dev/null +++ b/modules/nexus/components/SidebarHeader.qml @@ -0,0 +1,561 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus + +Item { + id: root + + required property NexusSession session + + readonly property bool collapsed: session.sidebarCollapsed + + property bool searchDropdownOpen: false + property bool configDropdownOpen: false + + // Build config model: Global + monitors + readonly property var configModel: { + const items = [ + { + id: "global", + label: "Global", + icon: "language", + desc: "Settings apply everywhere" + } + ]; + for (const screen of Screens.screens) { + items.push({ + id: screen.name, + label: screen.name, + icon: "monitor", + desc: "Monitor-specific overrides" + }); + } + return items; + } + + implicitHeight: headerLayout.implicitHeight + + ColumnLayout { + id: headerLayout + + anchors.fill: parent + spacing: 0 + + // Search bar + Item { + id: searchItem + + Layout.fillWidth: true + Layout.preferredHeight: root.collapsed ? 64 : 44 + + Behavior on Layout.preferredHeight { + Anim { + type: Anim.DefaultSpatial + } + } + + StyledRect { + id: searchBtn + + anchors.fill: parent + + radius: root.collapsed ? Tokens.rounding.normal : Tokens.rounding.full + color: { + if (root.session.searchPopoutOpen && root.collapsed) + return Qt.alpha(Colours.palette.m3secondaryContainer, 0.16); + if (!root.collapsed) + return Qt.alpha(Colours.palette.m3surfaceContainerHighest, 0.6); + return "transparent"; + } + + Behavior on radius { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on color { + CAnim {} + } + + // Collapsed mode: click to open popout + StateLayer { + visible: root.collapsed + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + root.session.searchPopoutOpen = !root.session.searchPopoutOpen; + root.session.configPopoutOpen = false; + } + } + + MaterialIcon { + id: searchIcon + + x: root.collapsed ? (parent.width - width) / 2 : Tokens.padding.large + y: root.collapsed ? (parent.height - height) / 2 - 8 : (parent.height - height) / 2 + + text: "search" + font.pointSize: root.collapsed ? Tokens.font.size.large : Tokens.font.size.larger + color: { + if (root.session.searchPopoutOpen && root.collapsed) + return Colours.palette.m3primary; + return Qt.alpha(Colours.palette.m3onSurface, root.collapsed ? 0.5 : 0.4); + } + + Behavior on x { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on font.pointSize { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on color { + CAnim {} + } + } + + // Collapsed label + StyledText { + visible: root.collapsed + x: (parent.width - width) / 2 + y: parent.height - height - 6 + + text: "Search" + font.pointSize: Tokens.font.size.small - 1 + color: { + if (root.session.searchPopoutOpen && root.collapsed) + return Colours.palette.m3primary; + return Qt.alpha(Colours.palette.m3onSurface, 0.7); + } + + opacity: root.collapsed ? 0.8 : 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on color { + CAnim {} + } + } + + // Expanded mode: text input + TextField { + id: searchField + + visible: !root.collapsed + anchors.left: searchIcon.right + anchors.leftMargin: Tokens.spacing.normal + anchors.right: parent.right + anchors.rightMargin: searchClear.visible ? searchClear.width + Tokens.spacing.normal : Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + + placeholderText: "Search settings..." + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurface + placeholderTextColor: Qt.alpha(Colours.palette.m3onSurface, 0.3) + background: Item {} + + onTextChanged: { + root.session.searchQuery = text; + root.searchDropdownOpen = text.length > 0; + } + onActiveFocusChanged: { + if (activeFocus && text.length > 0) + root.searchDropdownOpen = true; + else if (!activeFocus) + root.searchDropdownOpen = false; + } + + Connections { + function onSidebarCollapsedChanged() { + if (root.session.sidebarCollapsed) { + searchField.focus = false; + root.searchDropdownOpen = false; + } + } + + target: root.session + } + } + + // Clear button (expanded) + MaterialIcon { + id: searchClear + + visible: !root.collapsed && root.session.searchQuery.length > 0 + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.normal + anchors.verticalCenter: parent.verticalCenter + + text: "close" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + + StateLayer { + radius: Tokens.rounding.full + color: Colours.palette.m3onSurface + onClicked: { + searchField.text = ""; + root.session.searchQuery = ""; + root.searchDropdownOpen = false; + } + } + } + } + } + + Item { + id: configItem + + Layout.fillWidth: true + Layout.preferredHeight: root.collapsed ? 64 : 40 + Layout.topMargin: root.collapsed ? 4 : 8 + + Behavior on Layout.preferredHeight { + Anim { + type: Anim.DefaultSpatial + } + } + + StyledRect { + id: configBtn + + anchors.fill: parent + radius: root.collapsed ? Tokens.rounding.normal : Tokens.rounding.full + color: { + if (root.session.configPopoutOpen && root.collapsed) + return Qt.alpha(Colours.palette.m3secondaryContainer, 0.16); + if (root.configDropdownOpen && !root.collapsed) + return Qt.alpha(Colours.palette.m3secondaryContainer, 0.12); + if (!root.collapsed) + return Qt.alpha(Colours.palette.m3surfaceContainerHighest, 0.6); + return "transparent"; + } + + Behavior on radius { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on color { + CAnim {} + } + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + if (root.collapsed) { + root.session.configPopoutOpen = !root.session.configPopoutOpen; + root.session.searchPopoutOpen = false; + } else { + root.configDropdownOpen = !root.configDropdownOpen; + root.searchDropdownOpen = false; + } + } + } + + MaterialIcon { + id: configIcon + + x: root.collapsed ? (parent.width - width) / 2 : Tokens.padding.large + y: root.collapsed ? (parent.height - height) / 2 - 8 : (parent.height - height) / 2 + + text: root.session.activeConfig === "global" ? "language" : "monitor" + font.pointSize: root.collapsed ? Tokens.font.size.large : Tokens.font.size.larger + color: { + if (root.session.configPopoutOpen && root.collapsed) + return Colours.palette.m3primary; + if (root.configDropdownOpen && !root.collapsed) + return Colours.palette.m3primary; + return Qt.alpha(Colours.palette.m3onSurface, 0.5); + } + + Behavior on x { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on font.pointSize { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on color { + CAnim {} + } + } + + // Collapsed label + StyledText { + visible: root.collapsed + x: (parent.width - width) / 2 + y: parent.height - height - 6 + + text: root.session.activeConfig === "global" ? "Global" : root.session.activeConfig + font.pointSize: Tokens.font.size.small - 1 + font.weight: Font.Medium + color: { + if (root.session.configPopoutOpen && root.collapsed) + return Colours.palette.m3primary; + return Qt.alpha(Colours.palette.m3onSurface, 0.7); + } + + opacity: root.collapsed ? 0.8 : 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on color { + CAnim {} + } + } + + // Expanded label + StyledText { + visible: !root.collapsed + anchors.left: configIcon.right + anchors.leftMargin: Tokens.spacing.normal + anchors.verticalCenter: parent.verticalCenter + + text: root.session.activeConfig === "global" ? "Global" : root.session.activeConfig + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: { + if (root.configDropdownOpen) + return Colours.palette.m3primary; + return Qt.alpha(Colours.palette.m3onSurface, 0.6); + } + + Behavior on color { + CAnim {} + } + } + + MaterialIcon { + id: configChevron + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + + text: "expand_more" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.4) + rotation: (root.session.configPopoutOpen && root.collapsed) || (root.configDropdownOpen && !root.collapsed) ? 180 : 0 + opacity: root.collapsed ? 0 : 1 + + Behavior on rotation { + Anim { + type: Anim.StandardSmall + } + } + Behavior on opacity { + Anim { + type: Anim.StandardSmall + } + } + } + } + } + + // Separator + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + Layout.leftMargin: Tokens.padding.normal + Layout.rightMargin: Tokens.padding.normal + Layout.topMargin: root.collapsed ? 8 : Tokens.spacing.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.08) + } + } + + // Search results dropdown (expanded) + Rectangle { + id: searchDropdown + + z: 10 + x: 0 + y: searchItem.y + searchItem.height + 4 + width: root.width + height: root.searchDropdownOpen && !root.collapsed ? searchResultsCol.implicitHeight + Tokens.padding.normal * 2 : 0 + radius: Tokens.rounding.normal + color: Colours.tPalette.m3surfaceContainerHigh + clip: true + visible: height > 0 + + Behavior on height { + Anim { + type: Anim.Emphasized + } + } + + Column { + id: searchResultsCol + + anchors.fill: parent + anchors.margins: Tokens.padding.normal + spacing: 2 + + Repeater { + model: root.session.searchQuery.length > 0 ? NexusRegistry.searchSettings(root.session.searchQuery) : [] // qmllint disable missing-property + + delegate: Item { + id: searchResultDelegate + + required property var modelData + + width: searchResultsCol.width + height: 44 + + Row { + anchors.fill: parent + anchors.leftMargin: Tokens.spacing.normal + anchors.rightMargin: Tokens.spacing.normal + spacing: Tokens.spacing.normal + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 18 + height: 18 + radius: 5 + color: Qt.alpha(Colours.palette.m3primary, 0.1) + + MaterialIcon { + anchors.centerIn: parent + text: "arrow_forward" + font.pointSize: Tokens.font.size.small - 1 + color: Colours.palette.m3primary + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - parent.spacing - 32 + + StyledText { + text: searchResultDelegate.modelData.label + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3onSurface + } + StyledText { + text: searchResultDelegate.modelData.categoryLabel + (searchResultDelegate.modelData.tab ? " › " + searchResultDelegate.modelData.tab : "") + font.pointSize: Tokens.font.size.small - 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.4) + } + } + } + + StateLayer { + radius: Tokens.rounding.small + color: Colours.palette.m3onSurface + onClicked: { + searchField.text = ""; + root.session.searchQuery = ""; + root.session.setSearchNavigate(searchResultDelegate.modelData.categoryId, searchResultDelegate.modelData.tab || ""); + root.searchDropdownOpen = false; + } + } + } + } + } + } + + // Config dropdown (expanded) + Rectangle { + id: configDropdown + + z: 10 + x: 0 + y: configItem.y + configItem.height + 4 + width: root.width + height: root.configDropdownOpen && !root.collapsed ? configDropdownCol.implicitHeight + Tokens.padding.normal * 2 : 0 + radius: Tokens.rounding.normal + color: Colours.tPalette.m3surfaceContainerHigh + clip: true + visible: height > 0 + + Behavior on height { + NumberAnimation { + duration: 300 + easing: [0.34, 1.56, 0.64, 1, 1, 1] + } + } + + Column { + id: configDropdownCol + + anchors.fill: parent + anchors.margins: Tokens.padding.normal + spacing: 2 + + Repeater { + model: root.configModel + + delegate: Item { + id: configDropdownDelegate + + required property var modelData + readonly property bool isActive: root.session.activeConfig === modelData.id + + width: configDropdownCol.width + height: 44 + + Row { + anchors.fill: parent + anchors.leftMargin: Tokens.spacing.normal + anchors.rightMargin: Tokens.spacing.normal + spacing: Tokens.spacing.normal + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + text: configDropdownDelegate.modelData.icon + font.pointSize: Tokens.font.size.normal + color: configDropdownDelegate.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - parent.spacing - 24 + + StyledText { + text: configDropdownDelegate.modelData.label + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: configDropdownDelegate.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + } + StyledText { + text: configDropdownDelegate.modelData.desc + font.pointSize: Tokens.font.size.small - 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.4) + } + } + } + + StateLayer { + radius: Tokens.rounding.small + color: Colours.palette.m3onSurface + onClicked: { + root.session.activeConfig = configDropdownDelegate.modelData.id; + root.configDropdownOpen = false; + } + } + } + } + } + } +} diff --git a/modules/nexus/components/SidebarNavItem.qml b/modules/nexus/components/SidebarNavItem.qml new file mode 100644 index 000000000..de33bfb6e --- /dev/null +++ b/modules/nexus/components/SidebarNavItem.qml @@ -0,0 +1,203 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus + +Item { + id: root + + required property NexusSession session + required property var modelData + + readonly property string catId: modelData.id + readonly property bool isDirect: modelData.isDirect + readonly property bool hasChildren: modelData.children && modelData.children.length > 0 + readonly property bool isActive: session.activeCategory === catId + readonly property bool isChildActive: NexusRegistry.isChildActive(catId, session.activeCategory) // qmllint disable missing-property + readonly property bool collapsed: session.sidebarCollapsed + + property bool hovered: false + property bool flyoutActive: false + + signal flyoutRequested(real itemY) + signal flyoutCloseRequested + + width: parent ? parent.width : 0 + height: collapsed ? 68 : 40 + + Behavior on height { + Anim { + type: Anim.DefaultSpatial + } + } + + StyledRect { + id: navBtn + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.normal + + radius: root.collapsed ? Tokens.rounding.normal : Tokens.rounding.full + color: { + if (root.isActive || root.isChildActive) + return Qt.alpha(Colours.palette.m3primary, 0.16); + return "transparent"; + } + + Behavior on radius { + Anim { + type: Anim.DefaultSpatial + } + } + + Behavior on color { + CAnim {} + } + + StateLayer { + color: root.isActive || root.isChildActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + onClicked: { + if (root.isDirect) { + root.session.setCategory(root.catId); + } else if (root.collapsed) { + if (root.hasChildren) + root.session.setCategory(root.modelData.children[0].id); + } else { + root.session.expandedCategory = root.session.expandedCategory === root.catId ? "" : root.catId; + } + } + } + + MaterialIcon { + id: navIcon + + x: { + if (!root.collapsed) + return Tokens.padding.large; + const baseX = (parent.width - width) / 2; + return root.hasChildren && (root.hovered || root.flyoutActive) ? baseX - 6 : baseX; + } + y: root.collapsed ? (parent.height - height) / 2 - 10 : (parent.height - height) / 2 + + text: root.modelData.icon + color: root.isActive || root.isChildActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + font.pointSize: root.collapsed ? Tokens.font.size.large + 2 : Tokens.font.size.larger + fill: root.isActive || root.isChildActive ? 1 : 0 + scale: root.collapsed && root.hasChildren && (root.hovered || root.flyoutActive) ? 0.8 : 1.0 + + Behavior on scale { + Anim { + type: Anim.DefaultSpatial + } + } + + Behavior on x { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on font.pointSize { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on fill { + Anim {} + } + } + + StyledText { + id: navLabel + + x: root.collapsed ? (parent.width - width) / 2 : navIcon.x + navIcon.width + Tokens.spacing.normal + y: root.collapsed ? parent.height - height - 6 : (parent.height - height) / 2 + + text: root.collapsed ? (root.modelData.label.length > 8 ? root.modelData.label.substring(0, 7) + "…" : root.modelData.label) : root.modelData.label + color: root.isActive || root.isChildActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + font.pointSize: root.collapsed ? Tokens.font.size.small - 1 : Tokens.font.size.normal + font.capitalization: Font.Capitalize + font.weight: Font.Medium + + opacity: root.collapsed ? 0.8 : 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultSpatial + } + } + Behavior on font.pointSize { + Anim { + type: Anim.DefaultSpatial + } + } + } + + MaterialIcon { + id: navChevron + + visible: root.hasChildren && !root.collapsed + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + + text: "expand_more" + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + font.pointSize: Tokens.font.size.normal + rotation: root.session.expandedCategory === root.catId ? 180 : 0 + opacity: root.collapsed ? 0 : 1 + + Behavior on rotation { + Anim { + type: Anim.StandardSmall + } + } + + Behavior on opacity { + Anim { + type: Anim.StandardSmall + } + } + } + + MaterialIcon { + id: doubleChevron + + visible: root.collapsed && root.hasChildren + x: (parent.width - width) / 2 + 15 + y: (parent.height - height) / 2 - 10 + + text: "keyboard_double_arrow_right" + color: root.isActive || root.isChildActive ? Colours.palette.m3primary : Colours.palette.m3onSurface + font.pointSize: Tokens.font.size.large + opacity: (root.hovered || root.flyoutActive) ? 0.9 : 0.0 + scale: (root.hovered || root.flyoutActive) ? 0.9 : 0.6 + + Behavior on opacity { + Anim { + type: Anim.DefaultSpatial + } + } + + Behavior on scale { + Anim { + type: Anim.DefaultSpatial + } + } + } + + HoverHandler { + enabled: root.collapsed && root.hasChildren + onHoveredChanged: { + root.hovered = hovered; + if (hovered) { + root.flyoutRequested(root.mapToItem(null, 0, 0).y); + } else { + root.flyoutCloseRequested(); + } + } + } + } +} diff --git a/modules/nexus/components/SidebarPopout.qml b/modules/nexus/components/SidebarPopout.qml new file mode 100644 index 000000000..741d7cb30 --- /dev/null +++ b/modules/nexus/components/SidebarPopout.qml @@ -0,0 +1,121 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.components + +Item { + id: root + + required property bool open + property string popoutType: "" + property int popoutWidth: 320 + property int popoutPadding: 16 + property bool touchingTop: false + property real extraLeftMargin: 0 + property real flyoutDrawerWidth: 0 + property bool flyoutOpen: false + + readonly property real drawerWidth: drawer.width + readonly property real drawerHeight: drawer.height + + property string _prevType: "" + property Component _searchComponent: null + property Component _configComponent: null + + function setComponents(searchComp, configComp) { + _searchComponent = searchComp; + _configComponent = configComp; + } + + implicitWidth: drawer.width + implicitHeight: drawer.height + + onPopoutTypeChanged: { + if (popoutType === "") { + _prevType = ""; + contentFadeOut.start(); + } else if (_prevType === "") { + _prevType = popoutType; + contentContainer.opacity = 0; + contentFadeOut.stop(); + contentFadeIn.restart(); + } else { + contentFadeOut.start(); + } + } + + Rectangle { + id: drawer + + clip: true + width: root.open ? root.popoutWidth + root.extraLeftMargin : 0 + height: (contentLoader.item?.implicitHeight ?? 0) + root.popoutPadding * 2 // qmllint disable missing-property + + color: "transparent" + radius: 0 + + Behavior on width { + enabled: root.flyoutOpen === (root.flyoutDrawerWidth >= 100) + + Anim { + type: Anim.DefaultSpatial + } + } + + Behavior on height { + Anim { + type: Anim.DefaultSpatial + } + } + + Item { + id: contentContainer + + anchors.fill: parent + anchors.leftMargin: root.open ? root.popoutPadding + root.extraLeftMargin : 0 + anchors.rightMargin: root.open ? root.popoutPadding : 0 + anchors.topMargin: root.open ? root.popoutPadding : 0 + anchors.bottomMargin: root.open ? root.popoutPadding : 0 + opacity: 1 + + NumberAnimation { + id: contentFadeOut + + target: contentContainer + property: "opacity" + from: 1 + to: 0 + duration: 120 + onFinished: { + root._prevType = root.popoutType; + contentFadeIn.start(); + } + } + + NumberAnimation { + id: contentFadeIn + + target: contentContainer + property: "opacity" + from: 0 + to: 1 + duration: 250 + } + + Loader { + id: contentLoader + + anchors.fill: parent + sourceComponent: root._prevType === "search" ? root._searchComponent : root._prevType === "config" ? root._configComponent : null + } + + Behavior on anchors.leftMargin { + enabled: root.flyoutOpen === (root.flyoutDrawerWidth >= 100) + + Anim { + type: Anim.DefaultSpatial + } + } + } + } +} diff --git a/modules/nexus/components/common/TabStack.qml b/modules/nexus/components/common/TabStack.qml new file mode 100644 index 000000000..c58934512 --- /dev/null +++ b/modules/nexus/components/common/TabStack.qml @@ -0,0 +1,49 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts + +StackLayout { + id: root + + property int animateDuration: 200 + property real inactiveScale: 0.98 + + property int __prevCurrentIndex: 0 + + function __animateProperty(target, propertyName, toValue) { + const anim = Qt.createQmlObject('import qs.components; Anim {}', target, 'anim'); + anim.target = target; + anim.property = propertyName; + anim.to = toValue; + anim.start(); //qmllint disable missing-property + } + + clip: true + + onCurrentIndexChanged: { + const oldChild = root.children[__prevCurrentIndex]; + const newChild = root.children[currentIndex]; + + if (oldChild) { + __animateProperty(oldChild, "opacity", 0); + __animateProperty(oldChild, "scale", inactiveScale); + } + if (newChild) { + __animateProperty(newChild, "opacity", 1); + __animateProperty(newChild, "scale", 1); + } + + __prevCurrentIndex = currentIndex; + } + + Component.onCompleted: { + for (let i = 0; i < root.children.length; i++) { + const child = root.children[i]; + child.opacity = (i === currentIndex) ? 1 : 0; + child.scale = (i === currentIndex) ? 1 : inactiveScale; + child.visible = true; + } + __prevCurrentIndex = currentIndex; + } +} diff --git a/modules/nexus/components/common/TriStateRow.qml b/modules/nexus/components/common/TriStateRow.qml new file mode 100644 index 000000000..5e1caf21e --- /dev/null +++ b/modules/nexus/components/common/TriStateRow.qml @@ -0,0 +1,215 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import Caelestia.Config +import qs.components +import qs.services + +StyledRect { + id: root + + required property string label + property string value: "" + + signal triStateValueChanged(string newValue) + + Layout.fillWidth: true + implicitHeight: row.implicitHeight + Tokens.padding.large * 2 + radius: Tokens.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: row + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal + + StyledText { + Layout.fillWidth: true + text: root.label + } + + Item { + id: toggle + + property bool hovered: mouseArea.containsMouse + property bool pressed: mouseArea.pressed + property int toggleState: root.value === "enable" ? 2 : root.value === "" ? 1 : 0 + + implicitWidth: implicitHeight * 2.2 + implicitHeight: Tokens.font.size.normal + Tokens.padding.smaller * 2 + + Rectangle { + id: track + + anchors.fill: parent + radius: height / 2 + color: toggle.toggleState === 2 ? Colours.palette.m3primary : toggle.toggleState === 0 ? Colours.palette.m3error : Colours.layer(Colours.palette.m3surfaceContainerHighest, 1) + + Behavior on color { + CAnim {} + } + + Rectangle { + id: thumb + + readonly property real nonAnimWidth: toggle.pressed ? height * 1.3 : height + readonly property real thumbPadding: Tokens.padding.small / 2 + readonly property real availableWidth: parent.width - thumbPadding * 2 - height + readonly property real leftPos: thumbPadding + readonly property real centerPos: thumbPadding + availableWidth / 2 + readonly property real rightPos: thumbPadding + availableWidth + + radius: height / 2 + color: toggle.toggleState === 2 ? Colours.palette.m3onPrimary : toggle.toggleState === 0 ? Colours.palette.m3onError : Colours.layer(Colours.palette.m3outline, 2) + + x: toggle.toggleState === 2 ? rightPos : toggle.toggleState === 1 ? centerPos : leftPos + width: nonAnimWidth + height: parent.height - Tokens.padding.small + anchors.verticalCenter: parent.verticalCenter + + Behavior on x { + Anim {} + } + + Behavior on width { + Anim {} + } + + Behavior on color { + CAnim {} + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: toggle.toggleState === 2 ? Colours.palette.m3primary : Colours.palette.m3onSurface + opacity: toggle.pressed ? 0.1 : toggle.hovered ? 0.08 : 0 + + Behavior on opacity { + Anim {} + } + } + + Shape { + id: icon + + property point start1: { + if (toggle.pressed) + return Qt.point(width * 0.2, height / 2); + if (toggle.toggleState === 2) + return Qt.point(width * 0.85, height / 2); + if (toggle.toggleState === 1) + return Qt.point(width * 0.2, height / 2); + return Qt.point(width * 0.15, height * 0.15); + } + property point end1: { + if (toggle.pressed) { + if (toggle.toggleState === 2) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.8, height / 2); + } + if (toggle.toggleState === 2) + return Qt.point(width * 0.6, height * 0.3); + if (toggle.toggleState === 1) + return Qt.point(width * 0.8, height / 2); + return Qt.point(width * 0.85, height * 0.85); + } + property point start2: { + if (toggle.pressed) { + if (toggle.toggleState === 2) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.2, height / 2); + } + if (toggle.toggleState === 2) + return Qt.point(width * 0.6, height * 0.3); + if (toggle.toggleState === 1) + return Qt.point(width * 0.2, height / 2); + return Qt.point(width * 0.15, height * 0.85); + } + property point end2: { + if (toggle.pressed) + return Qt.point(width * 0.8, height / 2); + if (toggle.toggleState === 2) + return Qt.point(width * 0.15, height * 0.8); + if (toggle.toggleState === 1) + return Qt.point(width * 0.2, height / 2); + return Qt.point(width * 0.85, height * 0.15); + } + + anchors.centerIn: parent + width: height + height: parent.implicitHeight - Tokens.padding.small * 2 + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + strokeWidth: 3 // ~Tokens.font.size.larger * 0.15 + strokeColor: toggle.toggleState === 2 ? Colours.palette.m3primary : toggle.toggleState === 0 ? Colours.palette.m3error : Colours.palette.m3surfaceContainerHighest + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + startX: icon.start1.x + startY: icon.start1.y + + PathLine { + x: icon.end1.x + y: icon.end1.y + } + PathMove { + x: icon.start2.x + y: icon.start2.y + } + PathLine { + x: icon.end2.x + y: icon.end2.y + } + + Behavior on strokeColor { + CAnim {} + } + } + + Behavior on start1 { + Anim {} + } + Behavior on end1 { + Anim {} + } + Behavior on start2 { + Anim {} + } + Behavior on end2 { + Anim {} + } + } + } + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.value === "") + root.triStateValueChanged("enable"); + else if (root.value === "enable") + root.triStateValueChanged("disable"); + else + root.triStateValueChanged(""); + } + } + } + } +} diff --git a/modules/nexus/components/common/TriStateToggle.qml b/modules/nexus/components/common/TriStateToggle.qml new file mode 100644 index 000000000..3374c0188 --- /dev/null +++ b/modules/nexus/components/common/TriStateToggle.qml @@ -0,0 +1,101 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services + +Item { + id: root + + property string value: "" + + signal triStateChanged(string newValue) + + implicitHeight: row.implicitHeight + implicitWidth: row.implicitWidth + + RowLayout { + id: row + + anchors.fill: parent + spacing: Math.floor(Tokens.spacing.small / 2) + + Repeater { + model: [ + { + key: "disable", + icon: "close", + accent: "error" + }, + { + key: "", + icon: "remove", + accent: "surfaceContainer" + }, + { + key: "enable", + icon: "check", + accent: "primary" + } + ] + + delegate: StyledRect { + id: seg + + required property var modelData + + readonly property bool selected: root.value === modelData.key + readonly property color baseColor: { + if (!selected) + return Colours.layer(Colours.palette.m3surfaceContainerHigh, 1); + if (modelData.key === "enable") + return Colours.palette.m3primary; + if (modelData.key === "disable") + return Colours.palette.m3error; + return Colours.layer(Colours.palette.m3surfaceContainerHighest, 2); + } + readonly property color iconColor: { + if (!selected) + return Qt.alpha(Colours.palette.m3onSurface, 0.7); + if (modelData.key === "enable") + return Colours.palette.m3onPrimary; + if (modelData.key === "disable") + return Colours.palette.m3onError; + return Colours.palette.m3onSurface; + } + + Layout.preferredWidth: Tokens.font.size.large * 2 + Layout.preferredHeight: Tokens.font.size.large * 2 + radius: Tokens.rounding.full + color: baseColor + + Behavior on color { + CAnim {} + } + + StateLayer { + function onClicked(): void { + root.triStateChanged(seg.modelData.key); + } + + radius: Tokens.rounding.full + color: seg.iconColor + } + + MaterialIcon { + anchors.centerIn: parent + text: seg.modelData.icon + color: seg.iconColor + font.pointSize: Tokens.font.size.normal + fill: seg.selected ? 1 : 0 + + Behavior on color { + CAnim {} + } + } + } + } + } +} diff --git a/modules/nexus/components/power/BehaviorSection.qml b/modules/nexus/components/power/BehaviorSection.qml new file mode 100644 index 000000000..383569d6f --- /dev/null +++ b/modules/nexus/components/power/BehaviorSection.qml @@ -0,0 +1,122 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.modules.nexus.components.common + +ColumnLayout { + id: root + + required property string titleText + required property string section + required property var cfg + property bool showEvaluateThresholds: false + + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + StyledText { + text: root.titleText + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + } + + PowerProfileSelector { + Layout.fillWidth: true + opacity: root.enabled ? 1 : 0.4 + value: root.cfg?.setPowerProfile ?? "" + showRestore: true + showUnchanged: true + onProfileChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm[root.section]) + pm[root.section] = {}; + pm[root.section].setPowerProfile = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + RefreshRateSelector { + Layout.fillWidth: true + opacity: root.enabled ? 1 : 0.4 + value: root.cfg?.setRefreshRate ?? "" + showRestore: true + showUnchanged: true + onRateChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm[root.section]) + pm[root.section] = {}; + pm[root.section].setRefreshRate = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + TriStateRow { + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Animations") + value: root.cfg?.disableAnimations ?? "" + onTriStateValueChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm[root.section]) + pm[root.section] = {}; + pm[root.section].disableAnimations = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + TriStateRow { + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Blur") + value: root.cfg?.disableBlur ?? "" + onTriStateValueChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm[root.section]) + pm[root.section] = {}; + pm[root.section].disableBlur = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + TriStateRow { + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Rounding") + value: root.cfg?.disableRounding ?? "" + onTriStateValueChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm[root.section]) + pm[root.section] = {}; + pm[root.section].disableRounding = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + TriStateRow { + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Shadows") + value: root.cfg?.disableShadows ?? "" + onTriStateValueChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm[root.section]) + pm[root.section] = {}; + pm[root.section].disableShadows = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + SwitchRow { + visible: root.showEvaluateThresholds + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Evaluate battery thresholds") + checked: root.cfg?.evaluateThresholds ?? true + onToggled: c => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm[root.section]) + pm[root.section] = {}; + pm[root.section].evaluateThresholds = c; + GlobalConfig.general.battery.powerManagement = pm; + } + } +} diff --git a/modules/nexus/components/power/IdleTimeoutList.qml b/modules/nexus/components/power/IdleTimeoutList.qml new file mode 100644 index 000000000..7601705dc --- /dev/null +++ b/modules/nexus/components/power/IdleTimeoutList.qml @@ -0,0 +1,347 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +ColumnLayout { + id: root + + readonly property var timeouts: GlobalConfig.general.idle?.timeouts || [] + property bool _selfWriting: false + + function _syncFromConfig(): void { + timeoutsModel.clear(); + const list = root.timeouts || []; + for (const t of list) { + timeoutsModel.append({ + timeout: t.timeout ?? 300, + idleAction: typeof t.idleAction === "string" ? t.idleAction : JSON.stringify(t.idleAction), + returnAction: t.returnAction ?? "" + }); + } + } + + function _serialize(): var { + const out = []; + for (let i = 0; i < timeoutsModel.count; i++) { + const it = timeoutsModel.get(i); + let action; + try { + action = JSON.parse(it.idleAction); + } catch (e) { + action = it.idleAction; + } + out.push({ + timeout: it.timeout, + idleAction: action, + returnAction: it.returnAction + }); + } + return out; + } + + function _commit(): void { + _selfWriting = true; + GlobalConfig.general.idle.timeouts = JSON.parse(JSON.stringify(_serialize())); + Qt.callLater(() => _selfWriting = false); + } + + function updateField(idx: int, field: string, value: var): void { + if (idx < 0 || idx >= timeoutsModel.count) + return; + timeoutsModel.setProperty(idx, field, value); + _commit(); + } + + function removeAt(idx: int): void { + if (idx < 0 || idx >= timeoutsModel.count) + return; + timeoutsModel.remove(idx); + _commit(); + } + + function addTimeout(): void { + timeoutsModel.append({ + timeout: 300, + idleAction: "", + returnAction: "" + }); + _commit(); + } + + function formatDuration(seconds: int): string { + if (seconds < 60) + return seconds + qsTr("s"); + if (seconds < 3600) + return Math.floor(seconds / 60) + qsTr("m"); + return Math.floor(seconds / 3600) + qsTr("h") + " " + Math.floor((seconds % 3600) / 60) + qsTr("m"); + } + + function formatAction(action: string): string { + if (!action || action === "") + return qsTr("No action"); + if (action === "lock") + return qsTr("Lock screen"); + if (action === "dpms off") + return qsTr("Turn off display"); + if (action === "suspend" || action === "suspend-then-hibernate") + return qsTr("Suspend"); + if (action.startsWith("[")) + return qsTr("Custom command"); + return action; + } + + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + onTimeoutsChanged: { + if (_selfWriting) + return; + _syncFromConfig(); + } + + Component.onCompleted: _syncFromConfig() + + ListModel { + id: timeoutsModel + } + + ListView { + id: listView + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + spacing: Tokens.spacing.normal + model: timeoutsModel + clip: true + + header: Item { + width: ListView.view.width + height: 56 + Tokens.spacing.normal + + StyledRect { + anchors.fill: parent + anchors.bottomMargin: Tokens.spacing.normal + radius: Tokens.rounding.normal + color: Colours.palette.m3primary + + TapHandler { + onTapped: root.addTimeout() + } + + HoverHandler { + id: addBtnHover + + onHoveredChanged: parent.color = hovered ? Qt.lighter(Colours.palette.m3primary, 1.1) : Colours.palette.m3primary + } + + RowLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "add_circle" + font.pointSize: Tokens.font.size.large + color: Colours.palette.m3onPrimary + } + + StyledText { + text: qsTr("Add idle timeout") + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onPrimary + } + } + } + } + + add: Transition { + ParallelAnimation { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0.80 + to: 1 + } + } + } + remove: Transition { + ParallelAnimation { + Anim { + property: "opacity" + from: 1 + to: 0 + } + Anim { + property: "scale" + from: 1 + to: 0.80 + } + } + } + removeDisplaced: Transition { + Anim { + property: "y" + } + } + + footer: StyledText { + visible: timeoutsModel.count === 0 + width: ListView.view.width + horizontalAlignment: Text.AlignHCenter + text: qsTr("No idle timeouts configured") + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + font.pointSize: Tokens.font.size.small + } + + delegate: StyledRect { + id: card + + required property int index + required property int timeout + required property string idleAction + required property string returnAction + + property bool editing: false + + width: ListView.view.width + implicitHeight: (editing ? cardCol.implicitHeight : headerRow.implicitHeight) + Tokens.padding.large * 2 + radius: Tokens.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + Behavior on implicitHeight { + Anim {} + } + + ColumnLayout { + id: cardCol + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal + + RowLayout { + id: headerRow + + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + StyledText { + Layout.fillWidth: true + text: formatDuration(card.timeout) + " - " + formatAction(card.idleAction) //qmllint disable unqualified + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + } + + IconTextButton { + icon: card.editing ? "check" : "edit" + text: card.editing ? qsTr("Done") : qsTr("Edit") + onClicked: card.editing = !card.editing + } + + IconButton { + icon: "delete" + onClicked: root.removeAt(card.index) + } + } + + Loader { + Layout.fillWidth: true + active: card.editing + visible: active && opacity > 0 + opacity: card.editing ? 1 : 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.normal + + StyledRect { + Layout.fillWidth: true + implicitHeight: timeoutRow.implicitHeight + Tokens.padding.large * 2 + radius: Tokens.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 1) + + RowLayout { + id: timeoutRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Timeout (seconds)") + } + + CustomSpinBox { + min: 10 + max: 7200 + step: 10 + value: card.timeout + onValueModified: v => { + if (Math.round(v) !== card.timeout) + root.updateField(card.index, "timeout", Math.round(v)); + } + } + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: actionField.implicitHeight + Tokens.padding.normal * 2 + radius: Tokens.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 1) + + StyledTextField { + id: actionField + + anchors.fill: parent + anchors.margins: Tokens.padding.normal + text: card.idleAction + placeholderText: qsTr("Idle action: lock, dpms off, suspend, or custom command") + onEditingFinished: { + if (text !== card.idleAction) + root.updateField(card.index, "idleAction", text); + } + } + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: returnActionField.implicitHeight + Tokens.padding.normal * 2 + radius: Tokens.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 1) + + StyledTextField { + id: returnActionField + + anchors.fill: parent + anchors.margins: Tokens.padding.normal + text: card.returnAction + placeholderText: qsTr("Return action (optional): dpms on, etc.") + onEditingFinished: { + if (text !== card.returnAction) + root.updateField(card.index, "returnAction", text); + } + } + } + } + + Behavior on opacity { + Anim {} + } + } + } + } + } +} diff --git a/modules/nexus/components/power/PowerProfileSelector.qml b/modules/nexus/components/power/PowerProfileSelector.qml new file mode 100644 index 000000000..f9f6a784b --- /dev/null +++ b/modules/nexus/components/power/PowerProfileSelector.qml @@ -0,0 +1,41 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.components.controls + +SplitButtonRow { + id: root + + required property string value + property bool showRestore: false + property bool showUnchanged: false + + signal profileChanged(string newValue) + + label: qsTr("Power profile") + + menuItems: { + const items = []; + if (showRestore) { + items.push(Qt.createQmlObject(`import qs.components.controls; MenuItem { text: "Restore"; icon: "refresh"; property string val: "restore" }`, root, "restoreMenuItem")); + } + if (showUnchanged) { + items.push(Qt.createQmlObject(`import qs.components.controls; MenuItem { text: "Unchanged"; icon: "block"; property string val: "" }`, root, "unchangedMenuItem")); + } + items.push(Qt.createQmlObject(`import qs.components.controls; MenuItem { text: "Power Saver"; icon: "battery_saver"; property string val: "power-saver" }`, root, "powerSaverMenuItem")); + items.push(Qt.createQmlObject(`import qs.components.controls; MenuItem { text: "Balanced"; icon: "balance"; property string val: "balanced" }`, root, "balancedMenuItem")); + items.push(Qt.createQmlObject(`import qs.components.controls; MenuItem { text: "Performance"; icon: "speed"; property string val: "performance" }`, root, "performanceMenuItem")); + return items; + } + + Component.onCompleted: { + for (let i = 0; i < menuItems.length; i++) { + if (menuItems[i].val === root.value) { + active = menuItems[i]; + break; + } + } + } + //qmllint disable missing-property + onSelected: item => root.profileChanged(item.val) +} diff --git a/modules/nexus/components/power/ProfileBehaviorColumn.qml b/modules/nexus/components/power/ProfileBehaviorColumn.qml new file mode 100644 index 000000000..f64d5b0ed --- /dev/null +++ b/modules/nexus/components/power/ProfileBehaviorColumn.qml @@ -0,0 +1,101 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.modules.nexus.components.common + +ColumnLayout { + id: root + + required property string titleText + required property string profileKey + required property var cfg + + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + StyledText { + text: root.titleText + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + } + + RefreshRateSelector { + Layout.fillWidth: true + opacity: root.enabled ? 1 : 0.4 + value: root.cfg?.setRefreshRate ?? "" + showRestore: true + showUnchanged: false + onRateChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm.profileBehaviors) + pm.profileBehaviors = {}; + if (!pm.profileBehaviors[root.profileKey]) + pm.profileBehaviors[root.profileKey] = {}; + pm.profileBehaviors[root.profileKey].setRefreshRate = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + TriStateRow { + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Animations") + value: root.cfg?.disableAnimations ?? "" + onTriStateValueChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm.profileBehaviors) + pm.profileBehaviors = {}; + if (!pm.profileBehaviors[root.profileKey]) + pm.profileBehaviors[root.profileKey] = {}; + pm.profileBehaviors[root.profileKey].disableAnimations = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + TriStateRow { + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Blur") + value: root.cfg?.disableBlur ?? "" + onTriStateValueChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm.profileBehaviors) + pm.profileBehaviors = {}; + if (!pm.profileBehaviors[root.profileKey]) + pm.profileBehaviors[root.profileKey] = {}; + pm.profileBehaviors[root.profileKey].disableBlur = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + TriStateRow { + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Rounding") + value: root.cfg?.disableRounding ?? "" + onTriStateValueChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm.profileBehaviors) + pm.profileBehaviors = {}; + if (!pm.profileBehaviors[root.profileKey]) + pm.profileBehaviors[root.profileKey] = {}; + pm.profileBehaviors[root.profileKey].disableRounding = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } + + TriStateRow { + opacity: root.enabled ? 1 : 0.4 + label: qsTr("Shadows") + value: root.cfg?.disableShadows ?? "" + onTriStateValueChanged: v => { + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + if (!pm.profileBehaviors) + pm.profileBehaviors = {}; + if (!pm.profileBehaviors[root.profileKey]) + pm.profileBehaviors[root.profileKey] = {}; + pm.profileBehaviors[root.profileKey].disableShadows = v; + GlobalConfig.general.battery.powerManagement = pm; + } + } +} diff --git a/modules/nexus/components/power/RefreshRateSelector.qml b/modules/nexus/components/power/RefreshRateSelector.qml new file mode 100644 index 000000000..e1eee6d14 --- /dev/null +++ b/modules/nexus/components/power/RefreshRateSelector.qml @@ -0,0 +1,77 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.components.controls +import qs.services + +SplitButtonRow { + id: root + + required property string value + property bool showRestore: false + property bool showUnchanged: false + + property var availableRates: { + const rates = []; + if (showRestore) + rates.push({ + value: "restore", + label: qsTr("Restore"), + icon: "refresh" + }); + if (showUnchanged) + rates.push({ + value: "", + label: qsTr("Unchanged"), + icon: "block" + }); + + const monitors = Object.values(Hypr.monitors?.values || Hypr.monitors || {}); + const uniqueRates = new Set(); + for (const monitor of monitors) { + const data = monitor?.lastIpcObject; + if (data && data.availableModes) { + for (const mode of data.availableModes) { + const match = mode.match(/@(\d+(?:\.\d+)?)Hz/); + if (match) + uniqueRates.add(Math.round(parseFloat(match[1]))); + } + } + } + const sortedRates = Array.from(uniqueRates).sort((a, b) => a - b); + for (const rate of sortedRates) + rates.push({ + value: rate.toString(), + label: rate + " Hz", + icon: "speed" + }); + rates.push({ + value: "auto", + label: qsTr("Auto (lowest)"), + icon: "battery_saver" + }); + return rates; + } + + signal rateChanged(string newValue) + + label: qsTr("Refresh rate") + menuItems: { + const items = []; + for (const rate of availableRates) { + items.push(Qt.createQmlObject(`import qs.components.controls; MenuItem { text: "${rate.label}"; icon: "${rate.icon}"; property string val: "${rate.value}" }`, root, "dynamicMenuItem")); + } + return items; + } + + Component.onCompleted: { + for (let i = 0; i < menuItems.length; i++) { + if (menuItems[i].val === root.value) { + active = menuItems[i]; + break; + } + } + } + //qmllint disable missing-property + onSelected: item => root.rateChanged(item.val) +} diff --git a/modules/nexus/components/power/SegmentedSelector.qml b/modules/nexus/components/power/SegmentedSelector.qml new file mode 100644 index 000000000..739e9a7db --- /dev/null +++ b/modules/nexus/components/power/SegmentedSelector.qml @@ -0,0 +1,69 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Caelestia.Config +import qs.components +import qs.services + +Item { + id: root + + property string value: "" + property var options: [] + + signal selectionChanged(string newValue) + + implicitHeight: row.implicitHeight + implicitWidth: row.implicitWidth + + Row { + id: row + + anchors.fill: parent + spacing: Math.floor(Tokens.spacing.small / 2) + + Repeater { + model: root.options + + delegate: StyledRect { + id: seg + + required property var modelData + + readonly property bool selected: root.value === modelData.value + + height: Tokens.font.size.normal + Tokens.padding.small * 2 + width: Math.max(label.implicitWidth + Tokens.padding.normal * 2, Tokens.font.size.large * 3) + radius: Tokens.rounding.full + color: seg.selected ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 1) + + Behavior on color { + CAnim {} + } + + StateLayer { + function onClicked(): void { + root.selectionChanged(seg.modelData.value); + } + + radius: Tokens.rounding.full + color: seg.selected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + } + + StyledText { + id: label + + anchors.centerIn: parent + text: seg.modelData.label + color: seg.selected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + font.pointSize: Tokens.font.size.small + font.weight: seg.selected ? Font.Medium : Font.Normal + + Behavior on color { + CAnim {} + } + } + } + } + } +} diff --git a/modules/nexus/components/power/ThresholdList.qml b/modules/nexus/components/power/ThresholdList.qml new file mode 100644 index 000000000..a8028c6e8 --- /dev/null +++ b/modules/nexus/components/power/ThresholdList.qml @@ -0,0 +1,339 @@ +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.components.common + +ColumnLayout { + id: root + + required property var thresholds + property bool _selfWriting: false + + function _syncFromConfig(): void { + thresholdsModel.clear(); + const list = root.thresholds || []; + for (const t of list) { + thresholdsModel.append({ + level: t.level ?? 50, + setPowerProfile: t.setPowerProfile ?? "", + setRefreshRate: t.setRefreshRate ?? "", + disableAnimations: t.disableAnimations ?? "", + disableBlur: t.disableBlur ?? "", + disableRounding: t.disableRounding ?? "", + disableShadows: t.disableShadows ?? "" + }); + } + } + + function _serialize(): var { + const out = []; + for (let i = 0; i < thresholdsModel.count; i++) { + const it = thresholdsModel.get(i); + out.push({ + level: it.level, + setPowerProfile: it.setPowerProfile, + setRefreshRate: it.setRefreshRate, + disableAnimations: it.disableAnimations, + disableBlur: it.disableBlur, + disableRounding: it.disableRounding, + disableShadows: it.disableShadows + }); + } + return out; + } + + function _commit(): void { + _selfWriting = true; + const pm = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + pm.thresholds = _serialize(); + GlobalConfig.general.battery.powerManagement = pm; + Qt.callLater(() => _selfWriting = false); + } + + function updateField(idx: int, field: string, value: var): void { + if (idx < 0 || idx >= thresholdsModel.count) + return; + thresholdsModel.setProperty(idx, field, value); + _commit(); + } + + function removeAt(idx: int): void { + if (idx < 0 || idx >= thresholdsModel.count) + return; + thresholdsModel.remove(idx); + _commit(); + } + + function addThreshold(): void { + thresholdsModel.append({ + level: 50, + setPowerProfile: "", + setRefreshRate: "auto", + disableAnimations: "", + disableBlur: "", + disableRounding: "", + disableShadows: "" + }); + _commit(); + } + + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + onThresholdsChanged: { + if (_selfWriting) + return; + _syncFromConfig(); + } + + Component.onCompleted: _syncFromConfig() + + ListModel { + id: thresholdsModel + } + + ListView { + id: listView + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + spacing: Tokens.spacing.normal + model: thresholdsModel + clip: true + + add: Transition { + ParallelAnimation { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0.80 + to: 1 + } + } + } + remove: Transition { + ParallelAnimation { + Anim { + property: "opacity" + from: 1 + to: 0 + } + Anim { + property: "scale" + from: 1 + to: 0.80 + } + } + } + removeDisplaced: Transition { + Anim { + property: "y" + } + } + + footer: StyledText { + visible: thresholdsModel.count === 0 + width: ListView.view.width + horizontalAlignment: Text.AlignHCenter + text: qsTr("No thresholds configured") + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + font.pointSize: Tokens.font.size.small + } + + header: Item { + width: ListView.view.width + height: 56 + Tokens.spacing.normal + + StyledRect { + anchors.fill: parent + anchors.bottomMargin: Tokens.spacing.normal + radius: Tokens.rounding.normal + color: Colours.palette.m3primary + opacity: root.enabled ? 1 : 0.4 + + TapHandler { + onTapped: root.addThreshold() + } + + HoverHandler { + id: addBtnHover + + onHoveredChanged: parent.color = hovered ? Qt.lighter(Colours.palette.m3primary, 1.1) : Colours.palette.m3primary + } + + RowLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "add_circle" + font.pointSize: Tokens.font.size.large + color: Colours.palette.m3onPrimary + } + + StyledText { + text: qsTr("Add threshold") + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onPrimary + } + } + } + } + + delegate: StyledRect { + id: card + + required property int index + required property int level + required property string setPowerProfile + required property string setRefreshRate + required property string disableAnimations + required property string disableBlur + required property string disableRounding + required property string disableShadows + + property bool editing: false + + width: ListView.view.width + Layout.fillWidth: true + implicitHeight: (editing ? cardCol.implicitHeight : headerRow.implicitHeight) + Tokens.padding.large * 2 + radius: Tokens.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + opacity: root.enabled ? 1 : 0.4 + + Behavior on implicitHeight { + Anim {} + } + + ColumnLayout { + id: cardCol + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal + + RowLayout { + id: headerRow + + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + StyledText { + Layout.fillWidth: true + text: card.level + qsTr("% Battery") + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + } + + IconTextButton { + icon: card.editing ? "check" : "edit" + text: card.editing ? qsTr("Done") : qsTr("Edit") + onClicked: card.editing = !card.editing + } + + IconButton { + icon: "delete" + onClicked: root.removeAt(card.index) + } + } + + Loader { + Layout.fillWidth: true + active: card.editing + visible: active && opacity > 0 + opacity: card.editing ? 1 : 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.normal + + StyledRect { + Layout.fillWidth: true + implicitHeight: levelRow.implicitHeight + Tokens.padding.large * 2 + radius: Tokens.rounding.normal + color: Colours.layer(Colours.palette.m3surfaceContainer, 2) + + RowLayout { + id: levelRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.normal + + StyledText { + Layout.fillWidth: true + text: qsTr("Battery level") + } + + CustomSpinBox { + min: 5 + max: 95 + step: 1 + value: card.level + onValueModified: v => { + if (v !== card.level) + root.updateField(card.index, "level", Math.round(v)); + } + } + } + } + + PowerProfileSelector { + Layout.fillWidth: true + value: card.setPowerProfile + showUnchanged: true + onProfileChanged: v => root.updateField(card.index, "setPowerProfile", v) + } + + RefreshRateSelector { + Layout.fillWidth: true + value: card.setRefreshRate + showUnchanged: true + onRateChanged: v => root.updateField(card.index, "setRefreshRate", v) + } + + TriStateRow { + label: qsTr("Animations") + value: card.disableAnimations + onTriStateValueChanged: v => root.updateField(card.index, "disableAnimations", v) + } + + TriStateRow { + label: qsTr("Blur") + value: card.disableBlur + onTriStateValueChanged: v => root.updateField(card.index, "disableBlur", v) + } + + TriStateRow { + label: qsTr("Rounding") + value: card.disableRounding + onTriStateValueChanged: v => root.updateField(card.index, "disableRounding", v) + } + + TriStateRow { + label: qsTr("Shadows") + value: card.disableShadows + onTriStateValueChanged: v => root.updateField(card.index, "disableShadows", v) + } + } + + Behavior on opacity { + Anim {} + } + } + } + } + } +} diff --git a/modules/nexus/panels/About/Main.qml b/modules/nexus/panels/About/Main.qml new file mode 100644 index 000000000..757984099 --- /dev/null +++ b/modules/nexus/panels/About/Main.qml @@ -0,0 +1,471 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Caelestia.Config +import qs.components +import qs.services + +Item { + id: root + + property int activeTabIndex: 0 + + property string hostname: "" + property string quickshellVersion: "" + property string qtVersion: "" + property string distroName: "" + property string distroVersion: "" + property string kernelVersion: "" + property string shellVersion: "" + property string cliVersion: "" + property string deviceName: "" + property string firmwareVersion: "" + + readonly property string shellGitPath: { + const url = Qt.resolvedUrl("."); + const path = url.toString().replace(/^file:\/\//, ""); + const parts = path.split("/"); + parts.splice(-5); + const resolved = parts.join("/"); + return resolved; + } + + FileView { + id: hostnameFile + + path: "/etc/hostname" + onLoaded: root.hostname = text().trim() + } + + FileView { + id: osRelease + + path: "/etc/os-release" + onLoaded: { + const content = text(); + const nameMatch = content.match(/^PRETTY_NAME="([^"]+)"/m); + const idMatch = content.match(/^ID="([^"]+)"/m); + const versionMatch = content.match(/^VERSION_ID="([^"]+)"/m); + const nameLikeMatch = content.match(/^NAME="([^"]+)"/m); + + if (nameMatch) { + root.distroName = nameMatch[1]; + } else if (nameLikeMatch) { + root.distroName = nameLikeMatch[1]; + } else if (idMatch) { + root.distroName = idMatch[1]; + } else { + root.distroName = "Unknown"; + } + + if (versionMatch) { + root.distroVersion = versionMatch[1]; + } else { + root.distroVersion = ""; + } + } + } + + FileView { + id: procVersion + + path: "/proc/version" + onLoaded: { + const content = text(); + const match = content.match(/Linux version ([^\s]+)/); + root.kernelVersion = match ? match[1] : "Unknown"; + } + } + + Process { + id: quickshellVersionProc + + command: ["quickshell", "--version"] + running: true + stdout: StdioCollector { + onStreamFinished: { + const output = text.trim(); + const match = output.match(/quickshell[\s]+([\d.]+)/i); + root.quickshellVersion = match ? match[1] : output; + } + } + } + + Process { + id: qtVersionProc + + command: ["sh", "-c", "qmake6 --version 2>/dev/null | grep 'Qt version' | awk '{print $4}' || echo 'Unknown'"] + running: true + stdout: StdioCollector { + onStreamFinished: root.qtVersion = text.trim() || "Unknown" + } + } + + Process { + id: shellVersionProc + + command: ["sh", "-c", "git -C " + root.shellGitPath + " describe --tags 2>/dev/null || " + "caelestia -v 2>/dev/null | grep -o 'caelestia-shell [0-9.]*' | awk '{print $2}' || " + "pacman -Q caelestia-shell caelestia-shell-git 2>/dev/null | head -1 | awk '{print $2}'"] + running: true + stdout: StdioCollector { + onStreamFinished: root.shellVersion = text.trim() || "Unknown" + } + } + + Process { + id: cliVersionProc + + command: ["sh", "-c", "caelestia --version 2>/dev/null | head -1 | grep -oP '[0-9.]+' || " + "pip show caelestia 2>/dev/null | grep '^Version:' | awk '{print $2}' || " + "pacman -Q caelestia-cli 2>/dev/null | awk '{print $2}' || " + "echo 'Not installed'"] + running: true + stdout: StdioCollector { + onStreamFinished: root.cliVersion = text.trim() || "Unknown" + } + } + + Process { + id: deviceInfoProc + + command: ["sh", "-c", "cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null || echo 'Unknown Device'"] + running: true + stdout: StdioCollector { + onStreamFinished: root.deviceName = text.trim() || "Unknown Device" + } + } + + Process { + id: firmwareInfoProc + + command: ["sh", "-c", "cat /sys/devices/virtual/dmi/id/bios_version 2>/dev/null || echo 'Unknown'"] + running: true + stdout: StdioCollector { + onStreamFinished: root.firmwareVersion = text.trim() || "Unknown" + } + } + + ScrollView { + id: scrollView + + anchors.fill: parent + clip: true + + ColumnLayout { + width: scrollView.width + + // Header Section + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + Layout.bottomMargin: Tokens.padding.large * 3 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.large + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + + Logo { + Layout.preferredWidth: 100 + Layout.preferredHeight: 100 + Layout.bottomMargin: -35 + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + Layout.bottomMargin: -35 + + StyledText { + text: "Caelestia" + Layout.alignment: Qt.AlignLeft + Layout.bottomMargin: -10 + font.pointSize: Tokens.font.size.extraLarge + 8 + font.weight: Font.Light + color: Colours.palette.m3onSurface + } + + StyledText { + Layout.alignment: Qt.AlignLeft + text: "Version " + root.shellVersion + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + } + } + } + } + + GridLayout { + id: infoGrid + + Layout.fillWidth: true + columns: width < 500 ? 1 : 2 + columnSpacing: Tokens.spacing.large * 2 + rowSpacing: Tokens.spacing.large + + // System Section + InfoSection { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + + title: "System" + icon: "computer" + + InfoRow { + label: "Hostname" + value: root.hostname + } + InfoRow { + label: "Device" + value: root.deviceName + } + InfoRow { + label: "Distribution" + value: root.distroVersion ? root.distroName + " " + root.distroVersion : root.distroName + } + InfoRow { + label: "Kernel" + value: root.kernelVersion + } + InfoRow { + label: "Firmware" + value: root.firmwareVersion + } + } + + // Software Section + InfoSection { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + + title: "Software" + icon: "code" + + InfoRow { + label: "Shell" + value: root.shellVersion + } + InfoRow { + label: "CLI" + value: root.cliVersion + } + InfoRow { + label: "Quickshell" + value: root.quickshellVersion + } + InfoRow { + label: "Qt" + value: root.qtVersion + } + InfoRow { + label: "Plugins" + value: "0" + } + } + } + + // Links + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Tokens.padding.large * 2 + spacing: Tokens.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: "link" + font.pointSize: Tokens.font.size.large + color: Colours.palette.m3primary + } + + StyledText { + text: "Links" + font.pointSize: Tokens.font.size.large + font.weight: Font.Medium + color: Colours.palette.m3primary + } + + Item { + Layout.fillWidth: true + } + } + + // Divider + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.12) + } + + Flow { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + LinkButton { + implicitWidth: 140 + icon: "chat" + text: "Discord" + url: "https://discord.gg/BGDCFCmMBk" + } + + LinkButton { + implicitWidth: 140 + icon: "code" + text: "Shell Repo" + url: "https://github.com/caelestia-dots/shell" + } + + LinkButton { + implicitWidth: 140 + icon: "terminal" + text: "CLI Repo" + url: "https://github.com/caelestia-dots/cli" + } + } + } + + // Spacer at bottom + Item { + Layout.fillWidth: true + Layout.preferredHeight: Tokens.padding.large + } + } + } + + // Info row component + component InfoRow: RowLayout { + id: infoRow + + property string label: "" + property string value: "" + + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + StyledText { + text: infoRow.label + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: infoRow.value + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3onSurface + horizontalAlignment: Text.AlignRight + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + Layout.fillWidth: true + Layout.minimumWidth: 80 + } + } + + // Info section component + component InfoSection: ColumnLayout { + id: section + + property string title: "" + property string icon: "" + default property alias content: contentArea.children + + spacing: Tokens.spacing.normal + + // Section header + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: section.icon + font.pointSize: Tokens.font.size.large + color: Colours.palette.m3primary + } + + StyledText { + text: section.title + font.pointSize: Tokens.font.size.large + font.weight: Font.Medium + color: Colours.palette.m3primary + } + + Item { + Layout.fillWidth: true + } + } + + // Divider + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.12) + } + + ColumnLayout { + id: contentArea + + Layout.fillWidth: true + spacing: Tokens.spacing.small + } + } + + // Link button component - smaller text, more padding + component LinkButton: Rectangle { + id: linkButton + + property string icon: "" + property string text: "" + property string url: "" + + color: Colours.tPalette.m3surfaceContainer + radius: Tokens.rounding.normal + implicitHeight: 52 + + RowLayout { + id: linkLayout + + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + MaterialIcon { + text: linkButton.icon + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3primary + } + + StyledText { + text: linkButton.text + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3onSurface + } + } + + TapHandler { + onTapped: Qt.openUrlExternally(linkButton.url) + } + + Behavior on color { + ColorAnimation { + duration: Tokens.anim.durations.small + } + } + + HoverHandler { + id: hoverHandler + + onHoveredChanged: linkButton.color = hovered ? Colours.tPalette.m3surfaceContainerHigh : Colours.tPalette.m3surfaceContainer + } + } +} diff --git a/modules/nexus/panels/Appearance/Main.qml b/modules/nexus/panels/Appearance/Main.qml new file mode 100644 index 000000000..e0c84a72f --- /dev/null +++ b/modules/nexus/panels/Appearance/Main.qml @@ -0,0 +1,87 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services + +Item { + id: root + + property int activeTabIndex: 0 + + StackLayout { + anchors.fill: parent + currentIndex: root.activeTabIndex + + // Wallpaper & Scheme + Item { + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Wallpaper & Scheme" + font.pointSize: Tokens.font.size.larger + font.weight: Font.Medium + color: Colours.palette.m3onSurface + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Theme mode, color scheme, and wallpaper settings" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + } + } + + // Typography & Motion + Item { + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Typography & Motion" + font.pointSize: Tokens.font.size.larger + font.weight: Font.Medium + color: Colours.palette.m3onSurface + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Font and animation settings" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + } + } + + // Effects + Item { + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Effects" + font.pointSize: Tokens.font.size.larger + font.weight: Font.Medium + color: Colours.palette.m3onSurface + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Shadows, rounding, and visual effects" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + } + } + } +} diff --git a/modules/nexus/panels/PlaceholderPanel.qml b/modules/nexus/panels/PlaceholderPanel.qml new file mode 100644 index 000000000..b97796279 --- /dev/null +++ b/modules/nexus/panels/PlaceholderPanel.qml @@ -0,0 +1,60 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services + +Item { + id: root + + property int activeTabIndex: 0 + + StackLayout { + anchors.fill: parent + currentIndex: root.activeTabIndex + + Item { + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "construction" + font.pointSize: Tokens.font.size.extraLarge + color: Qt.alpha(Colours.palette.m3onSurface, 0.3) + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Panel not yet implemented") + font.pointSize: Tokens.font.size.larger + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("This settings page will be available in a future update.") + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.35) + } + } + } + + Item { + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Second tab placeholder") + font.pointSize: Tokens.font.size.larger + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + } + } + } +} diff --git a/modules/nexus/panels/Power/Main.qml b/modules/nexus/panels/Power/Main.qml new file mode 100644 index 000000000..ac2dd661d --- /dev/null +++ b/modules/nexus/panels/Power/Main.qml @@ -0,0 +1,344 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Services.UPower +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.components.power +import qs.modules.nexus.components.common + +Item { + id: root + + property int activeTabIndex: 0 + + readonly property bool hasBattery: UPower.displayDevice?.isLaptopBattery ?? false + + readonly property var pm: GlobalConfig.general.battery?.powerManagement + readonly property bool pmEnabled: pm && pm.enabled === true + readonly property var onCharging: (pm && pm.onCharging) || {} + readonly property var onUnplugged: (pm && pm.onUnplugged) || {} + readonly property var profileBehaviors: (pm && pm.profileBehaviors) || {} + readonly property var thresholds: (pm && pm.thresholds) || [] + + TabStack { + anchors.fill: parent + currentIndex: root.activeTabIndex + + // Tab 0: Inhibit and idle + Flickable { + id: inhibitFlick + + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: inhibitGrid.implicitHeight + Tokens.padding.large * 2 + clip: true + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + GridLayout { + id: inhibitGrid + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + columns: (width > 0 && width < 700) ? 1 : 2 + columnSpacing: Tokens.spacing.large * 2 + rowSpacing: Tokens.spacing.large + + // Section: Inhibit settings + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: Tokens.spacing.normal + + StyledText { + text: qsTr("Inhibit settings") + font.pointSize: Tokens.font.size.normal * 1.2 + font.weight: Font.Medium + } + + StyledText { + text: qsTr("Control when the system should stay awake") + font.pointSize: Tokens.font.size.small + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Inhibit when audio is playing") + checked: GlobalConfig.general.idle?.inhibitWhenAudio ?? false + onToggled: c => GlobalConfig.general.idle.inhibitWhenAudio = c + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Lock before sleep") + checked: GlobalConfig.general.idle?.lockBeforeSleep ?? false + onToggled: c => GlobalConfig.general.idle.lockBeforeSleep = c + } + } + + // Section: Idle timeouts + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: Tokens.spacing.normal + + StyledText { + text: qsTr("Idle timeouts") + font.pointSize: Tokens.font.size.normal * 1.2 + font.weight: Font.Medium + } + + StyledText { + text: qsTr("Actions to perform after periods of inactivity") + font.pointSize: Tokens.font.size.small + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + Loader { + id: idleListLoader + + Layout.fillWidth: true + sourceComponent: IdleTimeoutList {} + } + } + } + } + + // Tab 1: Battery & power behavior + Loader { + active: root.hasBattery + sourceComponent: Flickable { + id: thrFlick + + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: thrCol.implicitHeight + Tokens.padding.large * 2 + clip: true + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ColumnLayout { + id: thrCol + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Tokens.spacing.large + + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + text: qsTr("Current power profile") + font.pointSize: Tokens.font.size.normal * 1.2 + font.weight: Font.Medium + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + MaterialIcon { + text: { + switch (PowerProfiles.profile) { + case PowerProfile.PowerSaver: + return "battery_saver"; + case PowerProfile.Performance: + return "rocket_launch"; + default: + return "balance"; + } + } + color: Colours.palette.m3primary + font.pointSize: Tokens.font.size.large + } + + StyledText { + Layout.fillWidth: true + text: { + switch (PowerProfiles.profile) { + case PowerProfile.PowerSaver: + return qsTr("Power Saver"); + case PowerProfile.Performance: + return qsTr("Performance"); + default: + return qsTr("Balanced"); + } + } + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + } + + StyledText { + text: UPower.onBattery ? qsTr("On battery") : qsTr("Plugged in") + font.pointSize: Tokens.font.size.small + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + } + } + } + + SwitchRow { + Layout.bottomMargin: Tokens.spacing.larger * 2 + label: qsTr("Enable power management") + checked: root.pmEnabled + onToggled: c => { + const next = JSON.parse(JSON.stringify(GlobalConfig.general.battery.powerManagement || {})); + next.enabled = c; + GlobalConfig.general.battery.powerManagement = next; + } + } + + // Battery behavior + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.larger * 1.2 + spacing: Tokens.spacing.small + + StyledText { + text: qsTr("Battery & charging behavior") + font.pointSize: Tokens.font.size.normal * 1.2 + font.weight: Font.Medium + } + + StyledText { + text: qsTr("Define what happens when the battery reaches certain thresholds or charging status changes") + font.pointSize: Tokens.font.size.small + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.large + Layout.bottomMargin: Tokens.spacing.larger * 4 + spacing: Tokens.spacing.large + enabled: root.pmEnabled + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: 0 + Layout.alignment: Qt.AlignTop + spacing: Tokens.spacing.large + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.large + + BehaviorSection { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + titleText: qsTr("When plugged in") + section: "onCharging" + cfg: root.onCharging + } + + BehaviorSection { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + titleText: qsTr("When unplugged") + section: "onUnplugged" + cfg: root.onUnplugged + showEvaluateThresholds: true + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: 0 + Layout.maximumWidth: parent.width * 0.4 + Layout.alignment: Qt.AlignTop + spacing: Tokens.spacing.large + + StyledText { + text: qsTr("Battery Level Thresholds") + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + } + + ThresholdList { + Layout.fillWidth: true + enabled: root.pmEnabled + thresholds: root.thresholds + } + } + } + } + + // Power Profile Behaviors + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.larger * 1.2 + spacing: Tokens.spacing.small + enabled: root.pmEnabled + + StyledText { + text: qsTr("Power Profile Behaviors") + font.pointSize: Tokens.font.size.normal * 1.2 + font.weight: Font.Medium + } + + StyledText { + text: qsTr("Define what Hyprland settings to apply when each power profile is active") + font.pointSize: Tokens.font.size.small + color: Qt.alpha(Colours.palette.m3onSurface, 0.6) + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.large + spacing: Tokens.spacing.large + + ProfileBehaviorColumn { + Layout.fillWidth: true + Layout.preferredWidth: 0 + Layout.alignment: Qt.AlignTop + titleText: qsTr("Power Saver") + profileKey: "powerSaver" + cfg: root.profileBehaviors.powerSaver || ({}) + } + + ProfileBehaviorColumn { + Layout.fillWidth: true + Layout.preferredWidth: 0 + Layout.alignment: Qt.AlignTop + titleText: qsTr("Balanced") + profileKey: "balanced" + cfg: root.profileBehaviors.balanced || ({}) + } + + ProfileBehaviorColumn { + Layout.fillWidth: true + Layout.preferredWidth: 0 + Layout.alignment: Qt.AlignTop + titleText: qsTr("Performance") + profileKey: "performance" + cfg: root.profileBehaviors.performance || ({}) + } + } + } + } + } + } + } +} diff --git a/modules/nexus/panels/Updates/Main.qml b/modules/nexus/panels/Updates/Main.qml new file mode 100644 index 000000000..1c61feb05 --- /dev/null +++ b/modules/nexus/panels/Updates/Main.qml @@ -0,0 +1,480 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +Item { + id: root + + property int activeTabIndex: 0 + + property bool checking: false + property bool installing: false + property string installTarget: "" + property string statusMessage: "" + property int previewIndex: -1 + + property string cliGitPathSetting: Config.paths.cliGit + + property var packages: [ + { + key: "shell", + display: "Shell", + icon: "desktop_windows", + method: "git", + installed: "", + available: "", + hasUpdate: false, + preview: "", + badge: "git", + checking: false + }, + { + key: "cli", + display: "CLI", + icon: "terminal", + method: "git", + installed: "", + available: "", + hasUpdate: false, + preview: "", + badge: "git", + checking: false + }, + { + key: "quickshell", + display: "quickshell-git", + icon: "widgets", + method: "pacman", + installed: "", + available: "", + hasUpdate: false, + preview: "", + badge: "AUR", + checking: false + }, + { + key: "qt", + display: "Qt6", + icon: "code", + method: "pacman", + installed: "", + available: "", + hasUpdate: false, + preview: "", + badge: "pacman", + checking: false + } + ] + + readonly property int updateCount: { + let count = 0; + for (let i = 0; i < packages.length; i++) { + if (packages[i].hasUpdate) + count++; + } + return count; + } + + property int _checksRemaining: 0 + property var logic: null + + function saveCliPath(path: string) { + root.cliGitPathSetting = path; + GlobalConfig.paths.cliGit = path; + } + + function setPkg(idx, props) { + const pkgs = root.packages.slice(); + pkgs[idx] = Object.assign({}, pkgs[idx], props); + root.packages = pkgs; + } + + function checkDone() { + root._checksRemaining--; + if (root._checksRemaining <= 0) { + root.checking = false; + if (root.updateCount > 0) { + root.statusMessage = root.updateCount + " update" + (root.updateCount > 1 ? "s" : "") + " available"; + } else { + root.statusMessage = "Everything is up to date"; + } + } + } + + function checkForUpdates() { + if (!logic) { + retryTimer.start(); + return; + } + root.checking = true; + root.statusMessage = ""; + root.previewIndex = -1; + root._checksRemaining = 4; + root.setPkg(0, { + checking: true, + hasUpdate: false, + available: "", + preview: "" + }); + root.setPkg(1, { + checking: true, + hasUpdate: false, + available: "", + preview: "" + }); + root.setPkg(2, { + checking: true, + hasUpdate: false, + available: "", + preview: "" + }); + root.setPkg(3, { + checking: true, + hasUpdate: false, + available: "", + preview: "" + }); + logic.startCheck(); + } + + function updatePackage(idx) { + const pkg = root.packages[idx]; + root.installing = true; + root.installTarget = pkg.key; + if (pkg.method === "git") { + const repoPath = pkg.key === "shell" ? logic.shellGitPath : logic.cliGitPath; + root.statusMessage = "Pulling " + pkg.display + "..."; + logic.runGitPull(repoPath, pkg.key); + } else { + const pacmanName = pkg.key === "quickshell" ? "quickshell-git quickshell" : "qt6-base"; + root.statusMessage = "Updating " + pkg.display + "..."; + logic.runPacmanUpdate(pacmanName); + } + } + + function updateAll() { + const gitPkgs = []; + const pacmanPkgs = []; + for (let i = 0; i < packages.length; i++) { + if (!packages[i].hasUpdate) + continue; + if (packages[i].method === "git") + gitPkgs.push(i); + else + pacmanPkgs.push(i); + } + if (gitPkgs.length === 0 && pacmanPkgs.length === 0) + return; + root.installing = true; + root.installTarget = "all"; + let cmd = ""; + for (let i = 0; i < gitPkgs.length; i++) { + const pkg = packages[gitPkgs[i]]; + const repoPath = pkg.key === "shell" ? logic.shellGitPath : logic.cliGitPath; + const upstreamUrl = pkg.key === "shell" ? logic.shellUpstreamUrl : logic.cliUpstreamUrl; + if (repoPath) { + cmd += "echo '>>> Updating " + pkg.display + "' && " + "cd " + repoPath + " && " + "remote=$(git remote -v | grep '" + upstreamUrl + "' | head -1 | awk '{print $1}') && " + "if [ -z \"$remote\" ]; then remote='origin'; fi && " + "git fetch -q $remote main && git rebase $remote/main 2>&1 && "; + } + } + if (pacmanPkgs.length > 0) { + const names = []; + for (let i = 0; i < pacmanPkgs.length; i++) { + if (packages[pacmanPkgs[i]].key === "quickshell") + names.push("quickshell-git"); + else + names.push("qt6-base"); + } + cmd += "paru -S --noconfirm " + names.join(" ") + " 2>&1 || yay -S --noconfirm " + names.join(" ") + " 2>&1 || pkexec pacman -S --noconfirm " + names.join(" ") + " 2>&1 && "; + } + cmd += "echo 'Done'"; + root.statusMessage = "Updating all..."; + logic.runUpdateAll(cmd); + } + + function showPreview(idx) { + if (root.previewIndex === idx) { + root.previewIndex = -1; + return; + } + root.previewIndex = idx; + const pkg = root.packages[idx]; + if (pkg.preview) + return; + logic.loadPreview(idx, pkg.method, pkg.key); + } + + Loader { + id: logicLoader + + source: "UpdateLogic.qml" + onLoaded: { + item.panel = root; + root.logic = item; + root.checkForUpdates(); + } + } + + Timer { + id: retryTimer + + interval: 100 + onTriggered: root.checkForUpdates() + } + + ScrollView { + id: scrollView + + anchors.fill: parent + clip: true + + ColumnLayout { + width: scrollView.width + spacing: Tokens.spacing.large + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + Rectangle { + id: checkBtn + + implicitWidth: checkBtnLayout.implicitWidth + Tokens.padding.large * 2 + implicitHeight: 40 + radius: Tokens.rounding.full + color: root.checking ? Qt.alpha(Colours.palette.m3primary, 0.12) : Colours.tPalette.m3surfaceContainer + opacity: root.checking ? 0.6 : 1.0 + + RowLayout { + id: checkBtnLayout + + anchors.centerIn: parent + spacing: Tokens.spacing.small + + MaterialIcon { + text: "refresh" + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3primary + + RotationAnimation on rotation { + running: root.checking + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + } + } + + StyledText { + text: "Check for Updates" + font.pointSize: Tokens.font.size.small + font.weight: Font.Medium + color: Colours.palette.m3primary + } + } + + TapHandler { + enabled: !root.checking + onTapped: root.checkForUpdates() + } + + HoverHandler { + id: checkHover + + onHoveredChanged: checkBtn.color = root.checking ? Qt.alpha(Colours.palette.m3primary, 0.12) : (hovered ? Colours.tPalette.m3surfaceContainerHigh : Colours.tPalette.m3surfaceContainer) + } + + Behavior on color { + ColorAnimation { + duration: Tokens.anim.durations.small + } + } + } + StyledText { + text: root.checking ? "Checking for updates..." : root.statusMessage + font.pointSize: Tokens.font.size.small + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + } + + // Package list + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + + Repeater { + model: root.packages.length + + delegate: Loader { + required property int modelData + + Layout.fillWidth: true + source: "PackageRow.qml" + onLoaded: { + item.index = modelData; + item.pkg = Qt.binding(() => root.packages[modelData]); + item.expanded = Qt.binding(() => root.previewIndex === modelData); + item.panel = root; + } + } + } + } + + Rectangle { + id: updateAllBtn + + Layout.fillWidth: true + implicitHeight: 44 + radius: Tokens.rounding.full + color: root.updateCount > 0 ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + opacity: (root.updateCount > 0 && !root.installing) ? 1.0 : 0.5 + + StyledText { + anchors.centerIn: parent + text: root.installing && root.installTarget === "all" ? "Updating..." : "Update All" + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: root.updateCount > 0 ? Colours.palette.m3onPrimary : Qt.alpha(Colours.palette.m3onSurface, 0.4) + } + + TapHandler { + enabled: root.updateCount > 0 && !root.installing + onTapped: root.updateAll() + } + + HoverHandler { + id: updateAllHover + + onHoveredChanged: { + if (root.updateCount > 0 && !root.installing) { + updateAllBtn.color = hovered ? Qt.lighter(Colours.palette.m3primary, 1.1) : Colours.palette.m3primary; + } + } + } + + Behavior on color { + ColorAnimation { + duration: Tokens.anim.durations.small + } + } + } + + // Settings section + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.normal + Layout.topMargin: Tokens.spacing.normal * 2 + + StyledText { + text: "Settings" + font.pointSize: Tokens.font.size.normal + Layout.leftMargin: Tokens.padding.normal + font.weight: Font.Medium + color: Colours.palette.m3onSurfaceVariant + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: cliPathLayout.implicitHeight + Tokens.padding.normal * 4 + radius: Tokens.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: cliPathLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.normal * 2 + spacing: Tokens.spacing.small + + StyledText { + text: "CLI Git Path (for manual installs)" + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurface + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledRect { + Layout.fillWidth: true + implicitHeight: Math.max(pathIcon.implicitHeight, cliPathInput.implicitHeight) + radius: Tokens.rounding.full + color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + + MaterialIcon { + id: pathIcon + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.normal + + text: "folder" + color: Colours.palette.m3onSurfaceVariant + } + + StyledTextField { + id: cliPathInput + + anchors.left: pathIcon.right + anchors.right: parent.right + anchors.leftMargin: Tokens.spacing.small + anchors.rightMargin: Tokens.padding.normal + + topPadding: Tokens.padding.normal + bottomPadding: Tokens.padding.normal + + text: root.cliGitPathSetting + placeholderText: qsTr("Path to CLI git repo...") + + onEditingFinished: { + root.cliGitPathSetting = text; + } + } + } + + Rectangle { + implicitWidth: saveBtnText.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: 36 + radius: Tokens.rounding.full + color: saveBtnHover.hovered ? Qt.lighter(Colours.palette.m3primary, 1.1) : Colours.palette.m3primary + + StyledText { + id: saveBtnText + + anchors.centerIn: parent + text: "Save" + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3onPrimary + } + + TapHandler { + onTapped: { + root.saveCliPath(cliPathInput.text); + root.checkForUpdates(); + } + } + + HoverHandler { + id: saveBtnHover + } + } + } + } + } + } + + // Bottom spacer + Item { + Layout.fillWidth: true + Layout.preferredHeight: Tokens.padding.large + } + } + } +} diff --git a/modules/nexus/panels/Updates/PackageRow.qml b/modules/nexus/panels/Updates/PackageRow.qml new file mode 100644 index 000000000..9bfdafe9e --- /dev/null +++ b/modules/nexus/panels/Updates/PackageRow.qml @@ -0,0 +1,254 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services + +ColumnLayout { + id: pkgDelegate + + property int index: 0 + property var pkg: ({}) + property bool expanded: false + property var panel: null + + Layout.fillWidth: true + spacing: 0 + + Rectangle { + id: pkgRow + + Layout.fillWidth: true + implicitHeight: pkgContent.implicitHeight + Tokens.padding.larger * 2 + radius: Tokens.rounding.normal + color: pkgDelegate.pkg.hasUpdate ? Qt.alpha(Colours.palette.m3primary, 0.12) : Qt.alpha(Colours.palette.m3primary, 0.06) + + RowLayout { + id: pkgContent + + anchors.fill: parent + anchors.margins: Tokens.padding.larger + spacing: Tokens.spacing.normal + + Rectangle { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + radius: Tokens.rounding.small + color: pkgDelegate.pkg.hasUpdate ? Qt.alpha(Colours.palette.m3primary, 0.12) : Qt.alpha(Colours.palette.m3onSurface, 0.06) + + MaterialIcon { + id: pkgIcon + + property real _spinRotation: 0 + + anchors.centerIn: parent + text: pkgDelegate.pkg.checking ? "sync" : (pkgDelegate.pkg.hasUpdate ? "download" : "check_circle") + font.pointSize: Tokens.font.size.normal + color: pkgDelegate.pkg.checking ? Colours.palette.m3primary : (pkgDelegate.pkg.hasUpdate ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.5)) + rotation: pkgDelegate.pkg.checking ? _spinRotation : 0 + + NumberAnimation on _spinRotation { + running: pkgDelegate.pkg.checking + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + } + } + } + + // Name + badge + ColumnLayout { + spacing: 1 + + RowLayout { + spacing: Tokens.spacing.small + + StyledText { + text: pkgDelegate.pkg.display + font.pointSize: Tokens.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3onSurface + } + + // Install method badge + Rectangle { + visible: pkgDelegate.pkg.badge !== "" + implicitWidth: badgeText.implicitWidth + Tokens.padding.small * 4 + implicitHeight: badgeText.implicitHeight + 4 + radius: Tokens.rounding.small + color: pkgDelegate.pkg.badge === "git" ? Qt.alpha(Colours.palette.m3tertiary, 0.15) : Qt.alpha(Colours.palette.m3secondary, 0.15) + + StyledText { + id: badgeText + + anchors.centerIn: parent + text: pkgDelegate.pkg.badge + font.pointSize: Tokens.font.size.small - 2 + font.weight: Font.Medium + color: pkgDelegate.pkg.badge === "git" ? Colours.palette.m3tertiary : Colours.palette.m3secondary + } + } + } + } + + Item { + Layout.fillWidth: true + } + + // Version info + RowLayout { + spacing: Tokens.spacing.small + visible: pkgDelegate.pkg.installed !== "" + + StyledText { + text: pkgDelegate.pkg.installed + font.pointSize: Tokens.font.size.small + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + + StyledText { + text: "→" + font.pointSize: Tokens.font.size.small + color: Qt.alpha(Colours.palette.m3onSurface, 0.3) + visible: pkgDelegate.pkg.hasUpdate + } + + StyledText { + text: pkgDelegate.pkg.available + font.pointSize: Tokens.font.size.small + font.weight: Font.Medium + color: Colours.palette.m3primary + visible: pkgDelegate.pkg.hasUpdate + } + } + + Rectangle { + id: previewBtn + + implicitWidth: 32 + implicitHeight: 32 + radius: Tokens.rounding.full + color: pkgDelegate.expanded ? Qt.alpha(Colours.palette.m3primary, 0.12) : "transparent" + + MaterialIcon { + anchors.centerIn: parent + text: pkgDelegate.expanded ? "expand_less" : "expand_more" + font.pointSize: Tokens.font.size.normal + color: Qt.alpha(Colours.palette.m3onSurface, 0.5) + } + + TapHandler { + onTapped: pkgDelegate.panel.showPreview(pkgDelegate.index) + } + + HoverHandler { + id: previewHover + + onHoveredChanged: { + if (!pkgDelegate.expanded) { + previewBtn.color = hovered ? Qt.alpha(Colours.palette.m3onSurface, 0.06) : "transparent"; + } + } + } + } + + Rectangle { + id: pkgUpdateBtn + + visible: pkgDelegate.pkg.hasUpdate + implicitWidth: 32 + implicitHeight: 32 + radius: Tokens.rounding.full + color: Qt.alpha(Colours.palette.m3onSurface, 0.1) + opacity: pkgDelegate.panel.installing ? 0.5 : 1.0 + + MaterialIcon { + anchors.centerIn: parent + text: pkgDelegate.panel.installing && pkgDelegate.panel.installTarget === pkgDelegate.pkg.key ? "hourglass_empty" : "download" + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3primary + } + + TapHandler { + enabled: !pkgDelegate.panel.installing + onTapped: pkgDelegate.panel.updatePackage(pkgDelegate.index) + } + + HoverHandler { + id: pkgUpdateHover + + onHoveredChanged: pkgUpdateBtn.color = hovered ? Qt.alpha(Colours.palette.m3onSurface, 0.15) : Qt.alpha(Colours.palette.m3onSurface, 0.1) + } + + Behavior on color { + ColorAnimation { + duration: Tokens.anim.durations.small + } + } + } + } + + Behavior on color { + ColorAnimation { + duration: Tokens.anim.durations.small + } + } + } + + Rectangle { + id: previewPanel + + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.small + visible: pkgDelegate.expanded + implicitHeight: pkgDelegate.expanded ? previewContent.implicitHeight + Tokens.padding.large * 2 : 0 + radius: Tokens.rounding.small + color: Qt.alpha(Colours.palette.m3primary, 0.06) + + Behavior on implicitHeight { + NumberAnimation { + duration: Tokens.anim.durations.small + easing.type: Easing.OutCubic + } + } + + ColumnLayout { + id: previewContent + + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.small + + RowLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + text: pkgDelegate.pkg.method === "git" ? "commit" : "inventory_2" + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3primary + } + + StyledText { + text: pkgDelegate.pkg.method === "git" ? "Pending commits" : "Package info" + font.pointSize: Tokens.font.size.small + font.weight: Font.Medium + color: Colours.palette.m3primary + } + } + + StyledText { + Layout.fillWidth: true + text: pkgDelegate.pkg.preview || "Loading..." + font.pointSize: Tokens.font.size.small + font.family: "monospace" + color: Qt.alpha(Colours.palette.m3onSurface, 0.7) + wrapMode: Text.Wrap + maximumLineCount: 20 + elide: Text.ElideRight + } + } + } +} diff --git a/modules/nexus/panels/Updates/UpdateLogic.qml b/modules/nexus/panels/Updates/UpdateLogic.qml new file mode 100644 index 000000000..c792bfba2 --- /dev/null +++ b/modules/nexus/panels/Updates/UpdateLogic.qml @@ -0,0 +1,466 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io + +Item { + id: logic + + property var panel: null + + readonly property string shellUpstreamUrl: "https://github.com/caelestia-dots/shell.git" + readonly property string cliUpstreamUrl: "https://github.com/caelestia-dots/cli.git" + + readonly property string shellGitPath: { + const url = Qt.resolvedUrl("."); + const path = url.toString().replace(/^file:\/\//, ""); + const parts = path.split("/"); + parts.splice(-5); + const resolved = parts.join("/"); + return resolved; + } + property string cliGitPath: "" + + property Process shellVersionProc: Process { + command: ["sh", "-c", "git -C " + logic.shellGitPath + " describe --tags --match 'v*' --long 2>/dev/null || " + "git -C " + logic.shellGitPath + " describe --tags --match 'v*' 2>/dev/null || " + "caelestia -v 2>/dev/null | grep -oP 'caelestia-shell \\K[0-9.]+' || " + "git -C " + logic.shellGitPath + " rev-parse --short HEAD 2>/dev/null || echo 'Unknown'"] + running: false + stdout: StdioCollector { + onStreamFinished: { + if (logic.panel) { + logic.panel.setPkg(0, { + installed: text.trim() || "Unknown" + }); + logic.shellRemoteProc.running = true; + } + } + } + stderr: StdioCollector { + onStreamFinished: { + if (text.trim() && logic.panel) { + console.warn("shellVersionProc error:", text.trim()); + } + } + } + } + + property Process shellRemoteProc: Process { + command: ["sh", "-c", "LOCAL=$(git -C " + logic.shellGitPath + " rev-parse HEAD 2>/dev/null); " + "REMOTE=$(git ls-remote " + logic.shellUpstreamUrl + " refs/heads/main 2>/dev/null | cut -f1); " + "AHEAD=$(git -C " + logic.shellGitPath + " rev-list --count HEAD..$REMOTE 2>/dev/null || echo 0); " + "if [ \"$AHEAD\" -gt 0 ]; then " + "LATEST_TAG=$(git ls-remote --tags --sort=-v:refname " + logic.shellUpstreamUrl + " 'v*' 2>/dev/null | grep -v '\\^{}' | grep -vE 'april-fools|rc|beta|alpha|pre|dev' | head -1 | sed 's/.*refs\\/tags\\///'); " + "if [ -n \"$LATEST_TAG\" ]; then echo \"$LATEST_TAG+$AHEAD\"; else echo \"${REMOTE:0:7}+${AHEAD}\"; fi; " + "else echo ''; fi"] + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const remote = text.trim(); + if (remote) { + logic.panel.setPkg(0, { + available: remote, + hasUpdate: true, + checking: false + }); + } else { + logic.panel.setPkg(0, { + checking: false + }); + } + logic.panel.checkDone(); + } + } + stderr: StdioCollector { + onStreamFinished: { + if (text.trim()) + console.warn("shellRemoteProc error:", text.trim()); + } + } + } + + property Process cliDetectProc: Process { + command: ["sh", "-c", "CUSTOM=\"" + (logic.panel ? logic.panel.cliGitPathSetting : "") + "\"; " + "if [ -n \"$CUSTOM\" ] && [ -d \"$CUSTOM/.git\" ]; then " + " echo \"$CUSTOM\"; " + "else " + " echo ''; " + "fi"] + running: false + stdout: StdioCollector { + onStreamFinished: { + const path = text.trim(); + const customSet = logic.panel && logic.panel.cliGitPathSetting; + logic.cliGitPath = path; + logic.cliVersionProc.running = true; + } + } + } + + property Process cliVersionProc: Process { + command: ["sh", "-c", logic.cliGitPath ? "cd " + logic.cliGitPath + " && git describe --tags --abbrev=0 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo 'Unknown'" : "caelestia -v 2>/dev/null | head -1 | grep -oP '[0-9.]+' || pacman -Q caelestia-cli 2>/dev/null | awk '{print $2}' || echo 'Not found'"] + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const ver = text.trim(); + const badge = logic.cliGitPath ? "git" : "unknown"; + logic.panel.setPkg(1, { + installed: ver || "Not found", + badge: badge, + method: logic.cliGitPath ? "git" : "unknown", + checking: false + }); + if (logic.cliGitPath) { + logic.cliRemoteProc.running = true; + } else { + logic.panel.checkDone(); + } + } + } + } + + property Process cliRemoteProc: Process { + command: ["sh", "-c", logic.cliGitPath ? "LOCAL=$(git -C " + logic.cliGitPath + " rev-parse HEAD 2>/dev/null) && " + "REMOTE=$(git ls-remote " + logic.cliUpstreamUrl + " refs/heads/main 2>/dev/null | cut -f1) && " + "if [ -n \"$REMOTE\" ] && [ \"$LOCAL\" != \"$REMOTE\" ]; then " + " TAGS=$(git ls-remote --tags --sort=-v:refname " + logic.cliUpstreamUrl + " 'v*' 2>/dev/null | grep -v '\\^{}' | head -1 | sed 's/.*refs\\/tags\\///'); " + " if [ -n \"$TAGS\" ]; then echo \"$TAGS\"; else echo \"${REMOTE:0:7}\"; fi; " + "else echo ''; fi" : "echo ''"] + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const remote = text.trim(); + if (remote) { + logic.panel.setPkg(1, { + available: remote, + hasUpdate: true, + checking: false + }); + } else { + logic.panel.setPkg(1, { + checking: false + }); + } + logic.panel.checkDone(); + } + } + } + + property Process quickshellVersionProc: Process { + command: ["sh", "-c", "pacman -Q quickshell-git 2>/dev/null || pacman -Q quickshell 2>/dev/null || echo 'quickshell Not_installed'"] + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const parts = text.trim().split(/\s+/); + const pkgName = parts[0] || ""; + let ver = parts[1] || "Not installed"; + const badge = pkgName.includes("-git") ? "AUR" : (ver === "Not_installed" ? "" : "pacman"); + if (pkgName.includes("-git") && ver.includes(".r")) { + const match = ver.match(/^([0-9.]+)\.r(\d+)/); + if (match) + ver = match[1] + "-r" + match[2]; + } + logic.panel.setPkg(2, { + installed: ver.replace("Not_installed", "Not installed"), + badge: badge + }); + logic.quickshellUpdateCheckProc.running = true; + } + } + } + + property Process quickshellUpdateCheckProc: Process { + command: ["sh", "-c", "(checkupdates 2>/dev/null; paru -Qua 2>/dev/null || yay -Qua 2>/dev/null) | grep -E '^quickshell' || echo ''"] + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const line = text.trim(); + if (line) { + const parts = line.split(/\s+/); + if (parts.length >= 4 && parts[2] === "->") { + logic.panel.setPkg(2, { + available: parts[3], + hasUpdate: true, + checking: false + }); + } else { + logic.panel.setPkg(2, { + checking: false + }); + } + } else { + logic.panel.setPkg(2, { + checking: false + }); + } + logic.panel.checkDone(); + } + } + } + + property Process qtVersionProc: Process { + command: ["sh", "-c", "pacman -Q qt6-base 2>/dev/null | awk '{print $2}' || echo 'Not installed'"] + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + logic.panel.setPkg(3, { + installed: text.trim() || "Not installed", + badge: "pacman" + }); + logic.qtUpdateCheckProc.running = true; + } + } + } + + property Process qtUpdateCheckProc: Process { + command: ["sh", "-c", "checkupdates 2>/dev/null | grep '^qt6-base ' || echo ''"] + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const line = text.trim(); + if (line) { + const parts = line.split(/\s+/); + if (parts.length >= 4 && parts[2] === "->") { + logic.panel.setPkg(3, { + available: parts[3], + hasUpdate: true, + checking: false + }); + } else { + logic.panel.setPkg(3, { + checking: false + }); + } + } else { + logic.panel.setPkg(3, { + checking: false + }); + } + logic.panel.checkDone(); + } + } + } + + property Process previewProc: Process { + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const output = text.trim() || "No changes to preview"; + if (logic.panel.previewIndex >= 0) { + logic.panel.setPkg(logic.panel.previewIndex, { + preview: output + }); + } + } + } + } + + property string _expectedCommit: "" + property string _repoPollingFor: "" + + property Process expectedCommitProc: Process { + running: false + stdout: StdioCollector { + onStreamFinished: { + logic._expectedCommit = text.trim(); + } + } + } + + property Process headCheckProc: Process { + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel || !logic._repoPollingFor) + return; + const currentHead = text.trim(); + if (currentHead !== logic._expectedCommit && currentHead.length === 40) { + // HEAD changed, pull completed + logic.panel.statusMessage = "Update applied. Rechecking..."; + pullCompleteTimer.stop(); + logic._repoPollingFor = ""; + logic.panel.installing = false; + logic.panel.installTarget = ""; + logic.panel.checkForUpdates(); + } + } + } + } + + property Process gitPullProc: Process { + property string _repoKey: "" + + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const output = text.trim(); + if (!output) { + logic.panel.installing = false; + logic.panel.installTarget = ""; + logic.panel.statusMessage = "Update failed - no output from git"; + return; + } + if (output.includes("CONFLICT") || output.includes("Merge conflict") || output.includes("Automatic merge failed")) { + logic.panel.installing = false; + logic.panel.installTarget = ""; + logic.panel.statusMessage = "Merge conflicts - manual resolution required"; + const idx = logic.gitPullProc._repoKey === "shell" ? 0 : 1; //qmllint disable + logic.panel.setPkg(idx, { + hasUpdate: true, + preview: "Merge conflicts detected. Please resolve manually:\n" + output.substring(0, 500) + }); + } else if (output.includes("error:") || output.includes("fatal:")) { + logic.panel.installing = false; + logic.panel.installTarget = ""; + const idx = logic.gitPullProc._repoKey === "shell" ? 0 : 1; //qmllint disable + let errorMsg = "Update failed"; + if (output.includes("unstaged changes")) { + errorMsg = "Local changes block update - commit or stash first"; + } else if (output.includes("divergent")) { + errorMsg = "Divergent branches - manual merge required"; + } + logic.panel.statusMessage = errorMsg; + logic.panel.setPkg(idx, { + hasUpdate: true, + preview: "Git error:\n" + output.substring(0, 300) + }); + } else { + logic.panel.statusMessage = "Pull complete. Waiting for changes to apply..."; + pullCompleteTimer.start(); + } + } + } + stderr: StdioCollector { + onStreamFinished: { + const err = text.trim(); + if (err && logic.panel) { + // Surface git errors to UI + if (err.includes("error:") || err.includes("fatal:")) { + logic.panel.installing = false; + logic.panel.installTarget = ""; + const idx = logic.gitPullProc._repoKey === "shell" ? 0 : 1; //qmllint disable + let errorMsg = "Update failed"; + if (err.includes("unstaged changes")) { + errorMsg = "Local changes block update - commit or stash first"; + } else if (err.includes("divergent")) { + errorMsg = "Divergent branches - manual merge required"; + } + logic.panel.statusMessage = errorMsg; + logic.panel.setPkg(idx, { + hasUpdate: true, + preview: "Git error:\n" + err.substring(0, 300) + }); + } + } + } + } + } + + property Process pacmanUpdateProc: Process { + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + logic.panel.installing = false; + logic.panel.installTarget = ""; + logic.panel.statusMessage = "Update complete. Rechecking..."; + logic.panel.checkForUpdates(); + } + } + } + + property Process updateAllProc: Process { + running: false + stdout: StdioCollector { + onStreamFinished: { + if (!logic.panel) + return; + const output = text.trim(); + if (output.includes("CONFLICT") || output.includes("Merge conflict") || output.includes("Automatic merge failed")) { + logic.panel.installing = false; + logic.panel.installTarget = ""; + logic.panel.statusMessage = "Merge conflicts - manual resolution required"; + } else { + logic.panel.installing = false; + logic.panel.installTarget = ""; + logic.panel.statusMessage = "All updates complete. Rechecking..."; + logic.panel.checkForUpdates(); + } + } + } + stderr: StdioCollector { + onStreamFinished: { + if (text.trim() && logic.panel) { + console.warn("updateAllProc stderr:", text.trim()); + } + } + } + } + + function startCheck() { + shellVersionProc.running = true; + cliDetectProc.running = true; + quickshellVersionProc.running = true; + qtVersionProc.running = true; + } + + function runGitPull(repoPath: string, key: string) { + logic.gitPullProc._repoKey = key; + // Capture current HEAD before any operations + logic._repoPollingFor = key; + logic.expectedCommitProc.command = ["sh", "-c", "cd " + repoPath + " && git rev-parse HEAD"]; + logic.expectedCommitProc.running = true; + // Detect remote by upstream URL and rebase onto upstream/main + const upstreamUrl = key === "shell" ? logic.shellUpstreamUrl : logic.cliUpstreamUrl; + logic.gitPullProc.command = ["sh", "-c", "cd " + repoPath + " && " + "remote=$(git remote -v | grep '" + upstreamUrl + "' | head -1 | awk '{print $1}') && " + "if [ -z \"$remote\" ]; then remote='origin'; fi && " + "echo 'Fetching from' $remote/main && " + "git fetch -q $remote main 2>&1 && " + "git rebase $remote/main 2>&1"]; + logic.gitPullProc.running = true; + console.log("[Updates] gitPullProc started for", key); + } + + function runPacmanUpdate(pkgNames: string) { + pacmanUpdateProc.command = ["sh", "-c", "paru -S --noconfirm " + pkgNames + " 2>&1 || yay -S --noconfirm " + pkgNames + " 2>&1 || pkexec pacman -S --noconfirm " + pkgNames + " 2>&1"]; + pacmanUpdateProc.running = true; + } + + function runUpdateAll(cmd: string) { + updateAllProc.command = ["sh", "-c", cmd]; + updateAllProc.running = true; + } + + function loadPreview(idx: int, method: string, key: string) { + if (method === "git") { + const repoPath = key === "shell" ? shellGitPath : cliGitPath; + if (!repoPath) { + panel.setPkg(idx, { + preview: "Repository path unknown" + }); + return; + } + previewProc.command = ["sh", "-c", "cd " + repoPath + " && " + "git fetch -q " + (key === "shell" ? shellUpstreamUrl : cliUpstreamUrl) + " 2>/dev/null; " + "git log --oneline HEAD..FETCH_HEAD 2>/dev/null || echo 'No commits to preview'"]; + } else { + const pacmanName = key === "quickshell" ? "quickshell-git quickshell" : "qt6-base"; + previewProc.command = ["sh", "-c", "paru -Si " + pacmanName + " 2>/dev/null | grep -E '^(Name|Version|Description|URL)' || pacman -Si " + pacmanName + " 2>/dev/null | grep -E '^(Name|Version|Description|URL)' || echo 'No details available'"]; + } + previewProc.running = true; + } + + Timer { + id: pullCompleteTimer + + interval: 500 + repeat: true + onTriggered: { + if (!logic.panel || !logic._repoPollingFor) { + stop(); + return; + } + // Check if pull completed by comparing HEAD to expected + const repoPath = logic._repoPollingFor === "shell" ? logic.shellGitPath : logic.cliGitPath; + if (repoPath) { + logic.headCheckProc.command = ["sh", "-c", "cd " + repoPath + " && git rev-parse HEAD"]; + logic.headCheckProc.running = true; + } + } + } +} diff --git a/plugin/src/Caelestia/Blobs/shaders/blob.frag b/plugin/src/Caelestia/Blobs/shaders/blob.frag index e78531b65..196d0544a 100644 --- a/plugin/src/Caelestia/Blobs/shaders/blob.frag +++ b/plugin/src/Caelestia/Blobs/shaders/blob.frag @@ -218,6 +218,6 @@ void main() { discard; float fw = fwidth(mergedSdf); - float alpha = 1.0 - smoothstep(-fw, fw, mergedSdf); + float alpha = (1.0 - smoothstep(-fw, fw, mergedSdf)) * color.a; fragColor = vec4(color.rgb * alpha, alpha) * qt_Opacity; } diff --git a/plugin/src/Caelestia/Config/generalconfig.hpp b/plugin/src/Caelestia/Config/generalconfig.hpp index 73a4915d1..4084740d4 100644 --- a/plugin/src/Caelestia/Config/generalconfig.hpp +++ b/plugin/src/Caelestia/Config/generalconfig.hpp @@ -79,6 +79,51 @@ class GeneralBattery : public ConfigObject { }), }) CONFIG_GLOBAL_PROPERTY(int, criticalLevel, 3) + CONFIG_GLOBAL_PROPERTY(QVariantMap, powerManagement, + vmap({ + { u"enabled"_s, false }, + { u"thresholds"_s, QVariantList{} }, + { u"onCharging"_s, vmap({ + { u"setPowerProfile"_s, u"restore"_s }, + { u"setRefreshRate"_s, u"restore"_s }, + { u"disableAnimations"_s, u""_s }, + { u"disableBlur"_s, u""_s }, + { u"disableRounding"_s, u""_s }, + { u"disableShadows"_s, u""_s }, + }) }, + { u"onUnplugged"_s, vmap({ + { u"setPowerProfile"_s, u""_s }, + { u"setRefreshRate"_s, u""_s }, + { u"disableAnimations"_s, u""_s }, + { u"disableBlur"_s, u""_s }, + { u"disableRounding"_s, u""_s }, + { u"disableShadows"_s, u""_s }, + { u"evaluateThresholds"_s, true }, + }) }, + { u"profileBehaviors"_s, vmap({ + { u"powerSaver"_s, vmap({ + { u"setRefreshRate"_s, u""_s }, + { u"disableAnimations"_s, u""_s }, + { u"disableBlur"_s, u""_s }, + { u"disableRounding"_s, u""_s }, + { u"disableShadows"_s, u""_s }, + }) }, + { u"balanced"_s, vmap({ + { u"setRefreshRate"_s, u""_s }, + { u"disableAnimations"_s, u""_s }, + { u"disableBlur"_s, u""_s }, + { u"disableRounding"_s, u""_s }, + { u"disableShadows"_s, u""_s }, + }) }, + { u"performance"_s, vmap({ + { u"setRefreshRate"_s, u""_s }, + { u"disableAnimations"_s, u""_s }, + { u"disableBlur"_s, u""_s }, + { u"disableRounding"_s, u""_s }, + { u"disableShadows"_s, u""_s }, + }) }, + }) }, + })) public: explicit GeneralBattery(QObject* parent = nullptr) diff --git a/plugin/src/Caelestia/Config/userpaths.hpp b/plugin/src/Caelestia/Config/userpaths.hpp index da6973a78..613194966 100644 --- a/plugin/src/Caelestia/Config/userpaths.hpp +++ b/plugin/src/Caelestia/Config/userpaths.hpp @@ -21,6 +21,7 @@ class UserPaths : public ConfigObject { CONFIG_PROPERTY(QString, mediaGif, u"root:/assets/bongocat.gif"_s) CONFIG_PROPERTY(QString, noNotifsPic, u"root:/assets/dino.png"_s) CONFIG_PROPERTY(QString, lockNoNotifsPic, u"root:/assets/dino.png"_s) + CONFIG_PROPERTY(QString, cliGit, QString()) public: explicit UserPaths(QObject* parent = nullptr) diff --git a/plugin/src/Caelestia/Config/utilitiesconfig.hpp b/plugin/src/Caelestia/Config/utilitiesconfig.hpp index 3e5625bb0..0835dcde5 100644 --- a/plugin/src/Caelestia/Config/utilitiesconfig.hpp +++ b/plugin/src/Caelestia/Config/utilitiesconfig.hpp @@ -26,6 +26,7 @@ class UtilitiesToasts : public ConfigObject { CONFIG_GLOBAL_PROPERTY(bool, kbLimit, true) CONFIG_GLOBAL_PROPERTY(bool, vpnChanged, true) CONFIG_GLOBAL_PROPERTY(bool, nowPlaying, false) + CONFIG_GLOBAL_PROPERTY(bool, lowPowerModeChanged, true) public: explicit UtilitiesToasts(QObject* parent = nullptr)