diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 6797201..2e63d53 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -1,9 +1,10 @@ +pragma Singleton + import QtQuick import Quickshell import Quickshell.Io import qs.Commons import qs.Services -pragma Singleton Singleton { id: root @@ -116,9 +117,7 @@ Singleton { id: adapter // bar - property JsonObject bar - - bar: JsonObject { + property JsonObject bar: JsonObject { property string position: "top" // Possible values: "top", "bottom" property bool showActiveWindowIcon: true property bool alwaysShowBatteryPercentage: false @@ -130,14 +129,12 @@ Singleton { widgets: JsonObject { property list left: ["SystemMonitor", "ActiveWindow", "MediaMini"] property list center: ["Workspace"] - property list right: ["ScreenRecorderIndicator", "Tray", "NotificationHistory", "WiFi", "Bluetooth", "Battery", "Volume", "Brightness", "Clock", "SidePanelToggle"] + property list right: ["ScreenRecorderIndicator", "Tray", "ArchUpdater", "NotificationHistory", "WiFi", "Bluetooth", "Battery", "Volume", "Brightness", "Clock", "SidePanelToggle"] } } // general - property JsonObject general - - general: JsonObject { + property JsonObject general: JsonObject { property string avatarImage: defaultAvatar property bool dimDesktop: false property bool showScreenCorners: false @@ -145,9 +142,7 @@ Singleton { } // location - property JsonObject location - - location: JsonObject { + property JsonObject location: JsonObject { property string name: "Tokyo" property bool useFahrenheit: false property bool reverseDayMonth: false @@ -156,9 +151,7 @@ Singleton { } // screen recorder - property JsonObject screenRecorder - - screenRecorder: JsonObject { + property JsonObject screenRecorder: JsonObject { property string directory: "~/Videos" property int frameRate: 60 property string audioCodec: "opus" @@ -171,9 +164,7 @@ Singleton { } // wallpaper - property JsonObject wallpaper - - wallpaper: JsonObject { + property JsonObject wallpaper: JsonObject { property string directory: "/usr/share/wallpapers" property string current: "" property bool isRandom: false @@ -194,9 +185,7 @@ Singleton { } // applauncher - property JsonObject appLauncher - - appLauncher: JsonObject { + property JsonObject appLauncher: JsonObject { // When disabled, Launcher hides clipboard command and ignores cliphist property bool enableClipboardHistory: true // Position: center, top_left, top_right, bottom_left, bottom_right @@ -205,43 +194,34 @@ Singleton { } // dock - property JsonObject dock - - dock: JsonObject { + property JsonObject dock: JsonObject { property bool autoHide: false property bool exclusive: false property list monitors: [] } // network - property JsonObject network - - network: JsonObject { + property JsonObject network: JsonObject { property bool wifiEnabled: true property bool bluetoothEnabled: true } // notifications - property JsonObject notifications - - notifications: JsonObject { + property JsonObject notifications: JsonObject { property list monitors: [] } // audio - property JsonObject audio - - audio: JsonObject { + property JsonObject audio: JsonObject { property bool showMiniplayerAlbumArt: false property bool showMiniplayerCava: false property string visualizerType: "linear" property int volumeStep: 5 + property int cavaFrameRate: 60 } // ui - property JsonObject ui - - ui: JsonObject { + property JsonObject ui: JsonObject { property string fontDefault: "Roboto" // Default font for all text property string fontFixed: "DejaVu Sans Mono" // Fixed width font for terminal property string fontBillboard: "Inter" // Large bold font for clocks and prominent displays @@ -259,15 +239,11 @@ Singleton { } // brightness - property JsonObject brightness - - brightness: JsonObject { + property JsonObject brightness: JsonObject { property int brightnessStep: 5 } - property JsonObject colorSchemes - - colorSchemes: JsonObject { + property JsonObject colorSchemes: JsonObject { property bool useWallpaperColors: false property string predefinedScheme: "" property bool darkMode: true diff --git a/Commons/WidgetLoader.qml b/Commons/WidgetLoader.qml deleted file mode 100644 index ce44431..0000000 --- a/Commons/WidgetLoader.qml +++ /dev/null @@ -1,88 +0,0 @@ -import QtQuick -import qs.Commons - -QtObject { - id: root - - // Signal emitted when widget loading status changes - signal widgetLoaded(string widgetName) - signal widgetFailed(string widgetName, string error) - signal loadingComplete(int total, int loaded, int failed) - - // Properties to track loading status - property int totalWidgets: 0 - property int loadedWidgets: 0 - property int failedWidgets: 0 - - // Auto-discover widget components - function getWidgetComponent(widgetName) { - if (!widgetName || widgetName.trim() === "") { - return null - } - - const widgetPath = `../Modules/Bar/Widgets/${widgetName}.qml` - - // Try to load the widget directly from file - const component = Qt.createComponent(widgetPath) - if (component.status === Component.Ready) { - return component - } - - const errorMsg = `Failed to load ${widgetName}.qml widget, status: ${component.status}, error: ${component.errorString( - )}` - Logger.error("WidgetLoader", errorMsg) - return null - } - - // Initialize loading tracking - function initializeLoading(widgetList) { - totalWidgets = widgetList.length - loadedWidgets = 0 - failedWidgets = 0 - } - - // Track widget loading success - function onWidgetLoaded(widgetName) { - loadedWidgets++ - widgetLoaded(widgetName) - - if (loadedWidgets + failedWidgets === totalWidgets) { - Logger.log("WidgetLoader", `Loaded ${loadedWidgets} widgets`) - loadingComplete(totalWidgets, loadedWidgets, failedWidgets) - } - } - - // Track widget loading failure - function onWidgetFailed(widgetName, error) { - failedWidgets++ - widgetFailed(widgetName, error) - - if (loadedWidgets + failedWidgets === totalWidgets) { - loadingComplete(totalWidgets, loadedWidgets, failedWidgets) - } - } - - // This is where you should add your Modules/Bar/Widgets/ - // so it gets registered in the BarTab - function discoverAvailableWidgets() { - const widgetFiles = ["ActiveWindow", "Battery", "Bluetooth", "Brightness", "Clock", "MediaMini", "NotificationHistory", "ScreenRecorderIndicator", "SidePanelToggle", "SystemMonitor", "Tray", "Volume", "WiFi", "Workspace"] - - const availableWidgets = [] - - widgetFiles.forEach(widgetName => { - // Test if the widget can be loaded - const component = getWidgetComponent(widgetName) - if (component) { - availableWidgets.push({ - "key": widgetName, - "name": widgetName - }) - } - }) - - // Sort alphabetically - availableWidgets.sort((a, b) => a.name.localeCompare(b.name)) - - return availableWidgets - } -} diff --git a/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml b/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml new file mode 100644 index 0000000..d89e511 --- /dev/null +++ b/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml @@ -0,0 +1,230 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import qs.Commons +import qs.Services +import qs.Widgets + +NPanel { + id: root + panelWidth: 380 * scaling + panelHeight: 500 * scaling + panelAnchorRight: true + + // Auto-refresh when service updates + Connections { + target: ArchUpdaterService + function onUpdatePackagesChanged() { + // Force UI update when packages change + if (root.visible) { + // Small delay to ensure data is fully updated + Qt.callLater(() => { + // Force a UI update by triggering a property change + ArchUpdaterService.updatePackages = ArchUpdaterService.updatePackages + }, 100) + } + } + } + + panelContent: Rectangle { + color: Color.mSurface + radius: Style.radiusL * scaling + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL * scaling + spacing: Style.marginM * scaling + + // Header + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + NIcon { + text: "system_update" + font.pointSize: Style.fontSizeXXL * scaling + color: Color.mPrimary + } + + Text { + text: "System Updates" + font.pointSize: Style.fontSizeL * scaling + font.family: Settings.data.ui.fontDefault + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NIconButton { + icon: "close" + tooltipText: "Close" + sizeMultiplier: 0.8 + onClicked: root.close() + } + } + + NDivider { + Layout.fillWidth: true + } + + // Update summary + Text { + text: ArchUpdaterService.updatePackages.length + " package" + (ArchUpdaterService.updatePackages.length + !== 1 ? "s" : "") + " can be updated" + font.pointSize: Style.fontSizeL * scaling + font.family: Settings.data.ui.fontDefault + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + Layout.fillWidth: true + } + + // Package selection info + Text { + text: ArchUpdaterService.selectedPackagesCount + " of " + ArchUpdaterService.updatePackages.length + " packages selected" + font.pointSize: Style.fontSizeS * scaling + font.family: Settings.data.ui.fontDefault + color: Color.mOnSurfaceVariant + Layout.fillWidth: true + } + + // Package list + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Color.mSurfaceVariant + radius: Style.radiusM * scaling + + ListView { + id: packageListView + anchors.fill: parent + anchors.margins: Style.marginS * scaling + clip: true + model: ArchUpdaterService.updatePackages + spacing: Style.marginXS * scaling + + delegate: Rectangle { + width: packageListView.width + height: 50 * scaling + color: Color.transparent + radius: Style.radiusS * scaling + + RowLayout { + anchors.fill: parent + anchors.margins: Style.marginS * scaling + spacing: Style.marginS * scaling + + // Checkbox for selection + NIconButton { + id: checkbox + icon: "check_box_outline_blank" + onClicked: { + const isSelected = ArchUpdaterService.isPackageSelected(modelData.name) + if (isSelected) { + ArchUpdaterService.togglePackageSelection(modelData.name) + icon = "check_box_outline_blank" + colorFg = Color.mOnSurfaceVariant + } else { + ArchUpdaterService.togglePackageSelection(modelData.name) + icon = "check_box" + colorFg = Color.mPrimary + } + } + colorBg: Color.transparent + colorFg: Color.mOnSurfaceVariant + Layout.preferredWidth: 30 * scaling + Layout.preferredHeight: 30 * scaling + + Component.onCompleted: { + // Set initial state + if (ArchUpdaterService.isPackageSelected(modelData.name)) { + icon = "check_box" + colorFg = Color.mPrimary + } + } + } + + // Package info + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS * scaling + + Text { + text: modelData.name + font.pointSize: Style.fontSizeM * scaling + font.family: Settings.data.ui.fontDefault + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + Layout.fillWidth: true + } + + Text { + text: modelData.oldVersion + " → " + modelData.newVersion + font.pointSize: Style.fontSizeS * scaling + font.family: Settings.data.ui.fontDefault + color: Color.mOnSurfaceVariant + Layout.fillWidth: true + } + } + } + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + } + + // Action buttons + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS * scaling + + NIconButton { + icon: "refresh" + tooltipText: "Check for updates" + onClicked: { + ArchUpdaterService.doPoll() + } + colorBg: Color.mSurfaceVariant + colorFg: Color.mOnSurface + Layout.fillWidth: true + Layout.preferredHeight: 35 * scaling + } + + NIconButton { + icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "system_update" + tooltipText: ArchUpdaterService.updateInProgress ? "Update in progress..." : "Update all packages" + enabled: !ArchUpdaterService.updateInProgress + onClicked: { + ArchUpdaterService.runUpdate() + root.close() + } + colorBg: ArchUpdaterService.updateInProgress ? Color.mSurfaceVariant : Color.mPrimary + colorFg: ArchUpdaterService.updateInProgress ? Color.mOnSurfaceVariant : Color.mOnPrimary + Layout.fillWidth: true + Layout.preferredHeight: 35 * scaling + } + + NIconButton { + icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "settings" + tooltipText: ArchUpdaterService.updateInProgress ? "Update in progress..." : "Update selected packages" + enabled: !ArchUpdaterService.updateInProgress && ArchUpdaterService.selectedPackagesCount > 0 + onClicked: { + if (ArchUpdaterService.selectedPackagesCount > 0) { + ArchUpdaterService.runSelectiveUpdate() + root.close() + } + } + colorBg: ArchUpdaterService.updateInProgress ? Color.mSurfaceVariant : (ArchUpdaterService.selectedPackagesCount + > 0 ? Color.mSecondary : Color.mSurfaceVariant) + colorFg: ArchUpdaterService.updateInProgress ? Color.mOnSurfaceVariant : (ArchUpdaterService.selectedPackagesCount + > 0 ? Color.mOnSecondary : Color.mOnSurfaceVariant) + Layout.fillWidth: true + Layout.preferredHeight: 35 * scaling + } + } + } + } +} diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index ae0ad9f..eccb872 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Wayland +import Quickshell.Services.UPower import qs.Commons import qs.Services import qs.Widgets @@ -47,6 +48,7 @@ Variants { layer.enabled: true } + // ------------------------------ // Left Section - Dynamic Widgets Row { id: leftSection @@ -60,21 +62,19 @@ Variants { Repeater { model: Settings.data.bar.widgets.left delegate: Loader { - id: leftWidgetLoader - sourceComponent: widgetLoader.getWidgetComponent(modelData) active: true - anchors.verticalCenter: parent.verticalCenter - onStatusChanged: { - if (status === Loader.Error) { - widgetLoader.onWidgetFailed(modelData, "Loader error") - } else if (status === Loader.Ready) { - widgetLoader.onWidgetLoaded(modelData) + sourceComponent: NWidgetLoader { + widgetName: modelData + widgetProps: { + "screen": screen } } + anchors.verticalCenter: parent.verticalCenter } } } + // ------------------------------ // Center Section - Dynamic Widgets Row { id: centerSection @@ -87,21 +87,19 @@ Variants { Repeater { model: Settings.data.bar.widgets.center delegate: Loader { - id: centerWidgetLoader - sourceComponent: widgetLoader.getWidgetComponent(modelData) active: true - anchors.verticalCenter: parent.verticalCenter - onStatusChanged: { - if (status === Loader.Error) { - widgetLoader.onWidgetFailed(modelData, "Loader error") - } else if (status === Loader.Ready) { - widgetLoader.onWidgetLoaded(modelData) + sourceComponent: NWidgetLoader { + widgetName: modelData + widgetProps: { + "screen": screen } } + anchors.verticalCenter: parent.verticalCenter } } } + // ------------------------------ // Right Section - Dynamic Widgets Row { id: rightSection @@ -115,35 +113,17 @@ Variants { Repeater { model: Settings.data.bar.widgets.right delegate: Loader { - id: rightWidgetLoader - sourceComponent: widgetLoader.getWidgetComponent(modelData) active: true - anchors.verticalCenter: parent.verticalCenter - onStatusChanged: { - if (status === Loader.Error) { - widgetLoader.onWidgetFailed(modelData, "Loader error") - } else if (status === Loader.Ready) { - widgetLoader.onWidgetLoaded(modelData) + sourceComponent: NWidgetLoader { + widgetName: modelData + widgetProps: { + "screen": screen } } + anchors.verticalCenter: parent.verticalCenter } } } } - - // Widget loader instance - WidgetLoader { - id: widgetLoader - - onWidgetFailed: function (widgetName, error) { - Logger.error("Bar", `Widget failed: ${widgetName} - ${error}`) - } - } - - // Initialize widget loading tracking - Component.onCompleted: { - const allWidgets = [...Settings.data.bar.widgets.left, ...Settings.data.bar.widgets.center, ...Settings.data.bar.widgets.right] - widgetLoader.initializeLoading(allWidgets) - } } } diff --git a/Modules/Bar/Widgets/TrayMenu.qml b/Modules/Bar/Extras/TrayMenu.qml similarity index 100% rename from Modules/Bar/Widgets/TrayMenu.qml rename to Modules/Bar/Extras/TrayMenu.qml diff --git a/Modules/Bar/Widgets/ActiveWindow.qml b/Modules/Bar/Widgets/ActiveWindow.qml index 25af960..8e41fcc 100644 --- a/Modules/Bar/Widgets/ActiveWindow.qml +++ b/Modules/Bar/Widgets/ActiveWindow.qml @@ -9,17 +9,20 @@ import qs.Widgets Row { id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + property bool showingFullTitle: false + property int lastWindowIndex: -1 + anchors.verticalCenter: parent.verticalCenter spacing: Style.marginS * scaling visible: getTitle() !== "" - property bool showingFullTitle: false - property int lastWindowIndex: -1 - // Timer to hide full title after window switch Timer { id: fullTitleTimer - interval: Style.animationSlow * 4 // Show full title for 2 seconds + interval: 2000 repeat: false onTriggered: { showingFullTitle = false @@ -40,8 +43,9 @@ Row { } function getTitle() { - const focusedWindow = CompositorService.getFocusedWindow() - return focusedWindow ? (focusedWindow.title || focusedWindow.appId || "") : "" + // Use the service's focusedWindowTitle property which is updated immediately + // when WindowOpenedOrChanged events are received + return CompositorService.focusedWindowTitle !== "(No active window)" ? CompositorService.focusedWindowTitle : "" } function getAppIcon() { @@ -62,6 +66,7 @@ Row { Rectangle { // Let the Rectangle size itself based on its content (the Row) + visible: root.visible width: row.width + Style.marginM * scaling * 2 height: Math.round(Style.capsuleHeight * scaling) radius: Math.round(Style.radiusM * scaling) @@ -100,10 +105,10 @@ Row { NText { id: titleText - // If hovered or just switched window, show up to 300 pixels + // If hovered or just switched window, show up to 400 pixels // If not hovered show up to 150 pixels width: (showingFullTitle || mouseArea.containsMouse) ? Math.min(fullTitleMetrics.contentWidth, - 300 * scaling) : Math.min( + 400 * scaling) : Math.min( fullTitleMetrics.contentWidth, 150 * scaling) text: getTitle() font.pointSize: Style.fontSizeS * scaling diff --git a/Modules/Bar/Widgets/ArchUpdater.qml b/Modules/Bar/Widgets/ArchUpdater.qml new file mode 100644 index 0000000..4636840 --- /dev/null +++ b/Modules/Bar/Widgets/ArchUpdater.qml @@ -0,0 +1,75 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Services +import qs.Widgets + +NIconButton { + id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + + sizeMultiplier: 0.8 + colorBg: Color.mSurfaceVariant + colorFg: Color.mOnSurface + colorBorder: Color.transparent + colorBorderHover: Color.transparent + + // Enhanced icon states with better visual feedback + icon: { + if (ArchUpdaterService.busy) + return "sync" + if (ArchUpdaterService.updatePackages.length > 0) { + // Show different icons based on update count + const count = ArchUpdaterService.updatePackages.length + if (count > 50) + return "system_update_alt" // Many updates + if (count > 10) + return "system_update" // Moderate updates + return "system_update" // Few updates + } + return "task_alt" + } + + // Enhanced tooltip with more information + tooltipText: { + if (ArchUpdaterService.busy) + return "Checking for updates…" + + var count = ArchUpdaterService.updatePackages.length + if (count === 0) + return "System is up to date ✓" + + var header = count === 1 ? "One package can be upgraded:" : (count + " packages can be upgraded:") + + var list = ArchUpdaterService.updatePackages || [] + var s = "" + var limit = Math.min(list.length, 8) + // Reduced to 8 for better readability + for (var i = 0; i < limit; ++i) { + var p = list[i] + s += (i ? "\n" : "") + (p.name + ": " + p.oldVersion + " → " + p.newVersion) + } + if (list.length > 8) + s += "\n… and " + (list.length - 8) + " more" + + return header + "\n\n" + s + "\n\nClick to update system" + } + + // Enhanced click behavior with confirmation + onClicked: { + if (ArchUpdaterService.busy) + return + + if (ArchUpdaterService.updatePackages.length > 0) { + // Show confirmation dialog for updates + PanelService.getPanel("archUpdaterPanel").toggle(screen) + } else { + // Just refresh if no updates available + ArchUpdaterService.doPoll() + } + } +} diff --git a/Modules/Bar/Widgets/Battery.qml b/Modules/Bar/Widgets/Battery.qml index 8349da5..46f5701 100644 --- a/Modules/Bar/Widgets/Battery.qml +++ b/Modules/Bar/Widgets/Battery.qml @@ -6,89 +6,100 @@ import qs.Commons import qs.Services import qs.Widgets -NPill { +Item { id: root - // Test mode - property bool testMode: false - property int testPercent: 49 - property bool testCharging: false + property ShellScreen screen + property real scaling: ScalingService.scale(screen) - property var battery: UPower.displayDevice - property bool isReady: testMode ? true : (battery && battery.ready && battery.isLaptopBattery && battery.isPresent) - property real percent: testMode ? testPercent : (isReady ? (battery.percentage * 100) : 0) - property bool charging: testMode ? testCharging : (isReady ? battery.state === UPowerDeviceState.Charging : false) - property bool show: isReady && percent > 0 + implicitWidth: pill.width + implicitHeight: pill.height - // Choose icon based on charge and charging state - function batteryIcon() { + NPill { + id: pill - if (charging) - return "battery_android_bolt" + // Test mode + property bool testMode: false + property int testPercent: 49 + property bool testCharging: false - if (percent >= 95) - return "battery_android_full" + property var battery: UPower.displayDevice + property bool isReady: testMode ? true : (battery && battery.ready && battery.isLaptopBattery && battery.isPresent) + property real percent: testMode ? testPercent : (isReady ? (battery.percentage * 100) : 0) + property bool charging: testMode ? testCharging : (isReady ? battery.state === UPowerDeviceState.Charging : false) - // Hardcoded battery symbols - if (percent >= 85) - return "battery_android_6" - if (percent >= 70) - return "battery_android_5" - if (percent >= 55) - return "battery_android_4" - if (percent >= 40) - return "battery_android_3" - if (percent >= 25) - return "battery_android_2" - if (percent >= 10) - return "battery_android_1" - if (percent >= 0) - return "battery_android_0" - } + // Choose icon based on charge and charging state + function batteryIcon() { - visible: testMode || (isReady && battery.isLaptopBattery) + if (!isReady || !battery.isLaptopBattery) + return "battery_android_alert" - icon: root.batteryIcon() - text: Math.round(root.percent) + "%" - textColor: charging ? Color.mPrimary : Color.mOnSurface - forceShown: Settings.data.bar.alwaysShowBatteryPercentage - tooltipText: { - let lines = [] + if (charging) + return "battery_android_bolt" - if (testMode) { - lines.push("Time left: " + Time.formatVagueHumanReadableDuration(12345)) + if (percent >= 95) + return "battery_android_full" + + // Hardcoded battery symbols + if (percent >= 85) + return "battery_android_6" + if (percent >= 70) + return "battery_android_5" + if (percent >= 55) + return "battery_android_4" + if (percent >= 40) + return "battery_android_3" + if (percent >= 25) + return "battery_android_2" + if (percent >= 10) + return "battery_android_1" + if (percent >= 0) + return "battery_android_0" + } + + icon: batteryIcon() + text: (isReady && battery.isLaptopBattery) ? Math.round(percent) + "%" : "-" + textColor: charging ? Color.mPrimary : Color.mOnSurface + forceOpen: isReady && battery.isLaptopBattery && Settings.data.bar.alwaysShowBatteryPercentage + disableOpen: (!isReady || !battery.isLaptopBattery) + tooltipText: { + let lines = [] + + if (testMode) { + lines.push("Time Left: " + Time.formatVagueHumanReadableDuration(12345)) + return lines.join("\n") + } + + if (!isReady || !battery.isLaptopBattery) { + return "No Battery Detected" + } + + if (battery.timeToEmpty > 0) { + lines.push("Time Left: " + Time.formatVagueHumanReadableDuration(battery.timeToEmpty)) + } + + if (battery.timeToFull > 0) { + lines.push("Time Until Full: " + Time.formatVagueHumanReadableDuration(battery.timeToFull)) + } + + if (battery.changeRate !== undefined) { + const rate = battery.changeRate + if (rate > 0) { + lines.push(charging ? "Charging Rate: " + rate.toFixed(2) + " W" : "Discharging Rate: " + rate.toFixed( + 2) + " W") + } else if (rate < 0) { + lines.push("Discharging Rate: " + Math.abs(rate).toFixed(2) + " W") + } else { + lines.push("Estimating...") + } + } else { + lines.push(charging ? "Charging" : "Discharging") + } + + if (battery.healthPercentage !== undefined && battery.healthPercentage > 0) { + lines.push("Health: " + Math.round(battery.healthPercentage) + "%") + } return lines.join("\n") } - - if (!root.isReady) { - return "" - } - - if (root.battery.timeToEmpty > 0) { - lines.push("Time left: " + Time.formatVagueHumanReadableDuration(root.battery.timeToEmpty)) - } - - if (root.battery.timeToFull > 0) { - lines.push("Time until full: " + Time.formatVagueHumanReadableDuration(root.battery.timeToFull)) - } - - if (root.battery.changeRate !== undefined) { - const rate = root.battery.changeRate - if (rate > 0) { - lines.push(root.charging ? "Charging rate: " + rate.toFixed(2) + " W" : "Discharging rate: " + rate.toFixed( - 2) + " W") - } else if (rate < 0) { - lines.push("Discharging rate: " + Math.abs(rate).toFixed(2) + " W") - } else { - lines.push("Estimating...") - } - } else { - lines.push(root.charging ? "Charging" : "Discharging") - } - - if (root.battery.healthPercentage !== undefined && root.battery.healthPercentage > 0) { - lines.push("Health: " + Math.round(root.battery.healthPercentage) + "%") - } - return lines.join("\n") } } diff --git a/Modules/Bar/Widgets/Bluetooth.qml b/Modules/Bar/Widgets/Bluetooth.qml index bbe35f1..f977128 100644 --- a/Modules/Bar/Widgets/Bluetooth.qml +++ b/Modules/Bar/Widgets/Bluetooth.qml @@ -10,8 +10,11 @@ import qs.Widgets NIconButton { id: root - sizeMultiplier: 0.8 + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + visible: Settings.data.network.bluetoothEnabled + sizeMultiplier: 0.8 colorBg: Color.mSurfaceVariant colorFg: Color.mOnSurface colorBorder: Color.transparent @@ -28,7 +31,5 @@ NIconButton { } } tooltipText: "Bluetooth Devices" - onClicked: { - bluetoothPanel.toggle(screen) - } + onClicked: PanelService.getPanel("bluetoothPanel")?.toggle(screen) } diff --git a/Modules/Bar/Widgets/Brightness.qml b/Modules/Bar/Widgets/Brightness.qml index 58c8cc6..432a859 100644 --- a/Modules/Bar/Widgets/Brightness.qml +++ b/Modules/Bar/Widgets/Brightness.qml @@ -8,13 +8,16 @@ import qs.Widgets Item { id: root - width: pill.width - height: pill.height - visible: getMonitor() !== null + property ShellScreen screen + property real scaling: ScalingService.scale(screen) // Used to avoid opening the pill on Quickshell startup property bool firstBrightnessReceived: false + implicitWidth: pill.width + implicitHeight: pill.height + visible: getMonitor() !== null + function getMonitor() { return BrightnessService.getMonitorForScreen(screen) || null } diff --git a/Modules/Bar/Widgets/Clock.qml b/Modules/Bar/Widgets/Clock.qml index 1a269c9..ced71d8 100644 --- a/Modules/Bar/Widgets/Clock.qml +++ b/Modules/Bar/Widgets/Clock.qml @@ -1,16 +1,21 @@ import QtQuick +import Quickshell import qs.Commons import qs.Services import qs.Widgets -// Clock Icon with attached calendar Rectangle { id: root - width: clock.width + Style.marginM * 2 * scaling - height: Math.round(Style.capsuleHeight * scaling) + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + + implicitWidth: clock.width + Style.marginM * 2 * scaling + implicitHeight: Math.round(Style.capsuleHeight * scaling) radius: Math.round(Style.radiusM * scaling) color: Color.mSurfaceVariant + // Clock Icon with attached calendar NClock { id: clock anchors.verticalCenter: parent.verticalCenter @@ -24,7 +29,7 @@ Rectangle { } onEntered: { - if (!calendarPanel.active) { + if (!PanelService.getPanel("calendarPanel")?.active) { tooltip.show() } } @@ -33,7 +38,7 @@ Rectangle { } onClicked: { tooltip.hide() - calendarPanel.toggle(screen) + PanelService.getPanel("calendarPanel")?.toggle(screen) } } } diff --git a/Modules/Bar/Widgets/KeyboardLayout.qml b/Modules/Bar/Widgets/KeyboardLayout.qml new file mode 100644 index 0000000..0ee56e5 --- /dev/null +++ b/Modules/Bar/Widgets/KeyboardLayout.qml @@ -0,0 +1,36 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Io +import qs.Commons +import qs.Services +import qs.Widgets + +Row { + id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + + // Use the shared service for keyboard layout + property string currentLayout: KeyboardLayoutService.currentLayout + + width: pill.width + height: pill.height + + NPill { + id: pill + icon: "keyboard_alt" + iconCircleColor: Color.mPrimary + collapsedIconColor: Color.mOnSurface + autoHide: false // Important to be false so we can hover as long as we want + text: currentLayout + tooltipText: "Keyboard Layout: " + currentLayout + + onClicked: { + + // You could open keyboard settings here if needed + // For now, just show the current layout + } + } +} diff --git a/Modules/Bar/Widgets/MediaMini.qml b/Modules/Bar/Widgets/MediaMini.qml index cc0da68..dee6c42 100644 --- a/Modules/Bar/Widgets/MediaMini.qml +++ b/Modules/Bar/Widgets/MediaMini.qml @@ -9,10 +9,14 @@ import qs.Widgets Row { id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + anchors.verticalCenter: parent.verticalCenter spacing: Style.marginS * scaling - visible: MediaService.currentPlayer !== null - width: MediaService.currentPlayer !== null ? implicitWidth : 0 + visible: MediaService.currentPlayer !== null && MediaService.canPlay + width: MediaService.currentPlayer !== null && MediaService.canPlay ? implicitWidth : 0 function getTitle() { return MediaService.trackTitle + (MediaService.trackArtist !== "" ? ` - ${MediaService.trackArtist}` : "") @@ -144,7 +148,7 @@ Row { NText { id: titleText - // If hovered or just switched window, show up to 300 pixels + // If hovered or just switched window, show up to 400 pixels // If not hovered show up to 150 pixels width: (mouseArea.containsMouse) ? Math.min(fullTitleMetrics.contentWidth, 400 * scaling) : Math.min(fullTitleMetrics.contentWidth, diff --git a/Modules/Bar/Widgets/NotificationHistory.qml b/Modules/Bar/Widgets/NotificationHistory.qml index 8cf0502..15adeea 100644 --- a/Modules/Bar/Widgets/NotificationHistory.qml +++ b/Modules/Bar/Widgets/NotificationHistory.qml @@ -10,6 +10,9 @@ import qs.Widgets NIconButton { id: root + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + sizeMultiplier: 0.8 icon: "notifications" tooltipText: "Notification History" @@ -17,8 +20,5 @@ NIconButton { colorFg: Color.mOnSurface colorBorder: Color.transparent colorBorderHover: Color.transparent - - onClicked: { - notificationHistoryPanel.toggle(screen) - } + onClicked: PanelService.getPanel("notificationHistoryPanel")?.toggle(screen) } diff --git a/Modules/Bar/Widgets/PowerProfile.qml b/Modules/Bar/Widgets/PowerProfile.qml new file mode 100644 index 0000000..47fab43 --- /dev/null +++ b/Modules/Bar/Widgets/PowerProfile.qml @@ -0,0 +1,60 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.UPower +import qs.Commons +import qs.Services +import qs.Widgets + +NIconButton { + id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + property var powerProfiles: PowerProfiles + readonly property bool hasPP: powerProfiles.hasPerformanceProfile + + sizeMultiplier: 0.8 + visible: hasPP + + function profileIcon() { + if (!hasPP) + return "balance" + if (powerProfiles.profile === PowerProfile.Performance) + return "speed" + if (powerProfiles.profile === PowerProfile.Balanced) + return "balance" + if (powerProfiles.profile === PowerProfile.PowerSaver) + return "eco" + } + + function profileName() { + if (!hasPP) + return "Unknown" + if (powerProfiles.profile === PowerProfile.Performance) + return "Performance" + if (powerProfiles.profile === PowerProfile.Balanced) + return "Balanced" + if (powerProfiles.profile === PowerProfile.PowerSaver) + return "Power Saver" + } + + function changeProfile() { + if (!hasPP) + return + if (powerProfiles.profile === PowerProfile.Performance) + powerProfiles.profile = PowerProfile.PowerSaver + else if (powerProfiles.profile === PowerProfile.Balanced) + powerProfiles.profile = PowerProfile.Performance + else if (powerProfiles.profile === PowerProfile.PowerSaver) + powerProfiles.profile = PowerProfile.Balanced + } + + icon: root.profileIcon() + tooltipText: root.profileName() + colorBg: Color.mSurfaceVariant + colorFg: Color.mOnSurface + colorBorder: Color.transparent + colorBorderHover: Color.transparent + onClicked: root.changeProfile() +} diff --git a/Modules/Bar/Widgets/ScreenRecorderIndicator.qml b/Modules/Bar/Widgets/ScreenRecorderIndicator.qml index f7606c9..2df0ef3 100644 --- a/Modules/Bar/Widgets/ScreenRecorderIndicator.qml +++ b/Modules/Bar/Widgets/ScreenRecorderIndicator.qml @@ -1,18 +1,21 @@ +import Quickshell import qs.Commons import qs.Services import qs.Widgets // Screen Recording Indicator NIconButton { - id: screenRecordingIndicator + id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + + visible: ScreenRecorderService.isRecording icon: "videocam" tooltipText: "Screen Recording Active\nClick To Stop Recording" sizeMultiplier: 0.8 colorBg: Color.mPrimary colorFg: Color.mOnPrimary - visible: ScreenRecorderService.isRecording anchors.verticalCenter: parent.verticalCenter - onClicked: { - ScreenRecorderService.toggleRecording() - } + onClicked: ScreenRecorderService.toggleRecording() } diff --git a/Modules/Bar/Widgets/SidePanelToggle.qml b/Modules/Bar/Widgets/SidePanelToggle.qml index 42c634c..5b985b8 100644 --- a/Modules/Bar/Widgets/SidePanelToggle.qml +++ b/Modules/Bar/Widgets/SidePanelToggle.qml @@ -1,9 +1,14 @@ import Quickshell import qs.Commons import qs.Widgets +import qs.Services NIconButton { - id: sidePanelToggle + id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + icon: "widgets" tooltipText: "Open Side Panel" sizeMultiplier: 0.8 @@ -14,5 +19,5 @@ NIconButton { colorBorderHover: Color.transparent anchors.verticalCenter: parent.verticalCenter - onClicked: sidePanel.toggle(screen) + onClicked: PanelService.getPanel("sidePanel")?.toggle(screen) } diff --git a/Modules/Bar/Widgets/SystemMonitor.qml b/Modules/Bar/Widgets/SystemMonitor.qml index 18bb8b6..610a940 100644 --- a/Modules/Bar/Widgets/SystemMonitor.qml +++ b/Modules/Bar/Widgets/SystemMonitor.qml @@ -6,6 +6,10 @@ import qs.Widgets Row { id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + anchors.verticalCenter: parent.verticalCenter spacing: Style.marginS * scaling diff --git a/Modules/Bar/Widgets/Tray.qml b/Modules/Bar/Widgets/Tray.qml index 553e56b..3d97c85 100644 --- a/Modules/Bar/Widgets/Tray.qml +++ b/Modules/Bar/Widgets/Tray.qml @@ -6,15 +6,20 @@ import Quickshell import Quickshell.Services.SystemTray import Quickshell.Widgets import qs.Commons +import qs.Modules.Bar.Extras import qs.Services import qs.Widgets Rectangle { + id: root + + property ShellScreen screen + property real scaling: ScalingService.scale(screen) readonly property real itemSize: 24 * scaling visible: SystemTray.items.values.length > 0 - width: tray.width + Style.marginM * scaling * 2 - height: Math.round(Style.capsuleHeight * scaling) + implicitWidth: tray.width + Style.marginM * scaling * 2 + implicitHeight: Math.round(Style.capsuleHeight * scaling) radius: Math.round(Style.radiusM * scaling) color: Color.mSurfaceVariant @@ -134,9 +139,7 @@ Rectangle { function open() { visible = true - // Register into the panel service - // so this will autoclose if we open another panel - PanelService.registerOpen(trayPanel) + PanelService.willOpenPanel(trayPanel) } function close() { @@ -152,7 +155,7 @@ Rectangle { Loader { id: trayMenu - source: "TrayMenu.qml" + source: "../Extras/TrayMenu.qml" } } } diff --git a/Modules/Bar/Widgets/Volume.qml b/Modules/Bar/Widgets/Volume.qml index 36dbbb1..e115102 100644 --- a/Modules/Bar/Widgets/Volume.qml +++ b/Modules/Bar/Widgets/Volume.qml @@ -9,12 +9,15 @@ import qs.Widgets Item { id: root - width: pill.width - height: pill.height + property ShellScreen screen + property real scaling: ScalingService.scale(screen) // Used to avoid opening the pill on Quickshell startup property bool firstVolumeReceived: false + implicitWidth: pill.width + implicitHeight: pill.height + function getIcon() { if (AudioService.muted) { return "volume_off" @@ -64,6 +67,7 @@ Item { } } onClicked: { + var settingsPanel = PanelService.getPanel("settingsPanel") settingsPanel.requestedTab = SettingsPanel.Tab.AudioService settingsPanel.open(screen) } diff --git a/Modules/Bar/Widgets/WiFi.qml b/Modules/Bar/Widgets/WiFi.qml index 83b4689..845a110 100644 --- a/Modules/Bar/Widgets/WiFi.qml +++ b/Modules/Bar/Widgets/WiFi.qml @@ -10,6 +10,11 @@ import qs.Widgets NIconButton { id: root + property ShellScreen screen + property real scaling: ScalingService.scale(screen) + + visible: Settings.data.network.wifiEnabled + sizeMultiplier: 0.8 Component.onCompleted: { @@ -27,6 +32,8 @@ NIconButton { icon: { try { + if (NetworkService.ethernet) + return "lan" let connected = false let signalStrength = 0 for (const net in NetworkService.networks) { @@ -36,17 +43,17 @@ NIconButton { break } } - return connected ? NetworkService.signalIcon(signalStrength) : "wifi" + return connected ? NetworkService.signalIcon(signalStrength) : "wifi_find" } catch (error) { Logger.error("WiFi", "Error getting icon:", error) - return "wifi" + return "signal_wifi_bad" } } - tooltipText: "WiFi Networks" + tooltipText: "Network / WiFi" onClicked: { try { Logger.log("WiFi", "Button clicked, toggling panel") - wifiPanel.toggle(screen) + PanelService.getPanel("wifiPanel")?.toggle(screen) } catch (error) { Logger.error("WiFi", "Error toggling panel:", error) } diff --git a/Modules/Bar/Widgets/Workspace.qml b/Modules/Bar/Widgets/Workspace.qml index 220a6b3..0871ce7 100644 --- a/Modules/Bar/Widgets/Workspace.qml +++ b/Modules/Bar/Widgets/Workspace.qml @@ -10,6 +10,10 @@ import qs.Services Item { id: root + + property ShellScreen screen: null + property real scaling: ScalingService.scale(screen) + property bool isDestroying: false property bool hovered: false @@ -23,7 +27,8 @@ Item { signal workspaceChanged(int workspaceId, color accentColor) - width: { + implicitHeight: Math.round(36 * scaling) + implicitWidth: { let total = 0 for (var i = 0; i < localWorkspaces.count; i++) { const ws = localWorkspaces.get(i) @@ -39,34 +44,35 @@ Item { return total } - height: Math.round(36 * scaling) - Component.onCompleted: { - localWorkspaces.clear() - for (var i = 0; i < WorkspaceService.workspaces.count; i++) { - const ws = WorkspaceService.workspaces.get(i) - if (ws.output.toLowerCase() === screen.name.toLowerCase()) { - localWorkspaces.append(ws) - } - } - workspaceRepeater.model = localWorkspaces - updateWorkspaceFocus() + refreshWorkspaces() } + Component.onDestruction: { + root.isDestroying = true + } + + onScreenChanged: refreshWorkspaces() + Connections { target: WorkspaceService function onWorkspacesChanged() { - localWorkspaces.clear() + refreshWorkspaces() + } + } + + function refreshWorkspaces() { + localWorkspaces.clear() + if (screen !== null) { for (var i = 0; i < WorkspaceService.workspaces.count; i++) { const ws = WorkspaceService.workspaces.get(i) if (ws.output.toLowerCase() === screen.name.toLowerCase()) { localWorkspaces.append(ws) } } - - workspaceRepeater.model = localWorkspaces - updateWorkspaceFocus() } + workspaceRepeater.model = localWorkspaces + updateWorkspaceFocus() } function triggerUnifiedWave() { @@ -74,6 +80,17 @@ Item { masterAnimation.restart() } + function updateWorkspaceFocus() { + for (var i = 0; i < localWorkspaces.count; i++) { + const ws = localWorkspaces.get(i) + if (ws.isFocused === true) { + root.triggerUnifiedWave() + root.workspaceChanged(ws.id, Color.mPrimary) + break + } + } + } + SequentialAnimation { id: masterAnimation PropertyAction { @@ -101,17 +118,6 @@ Item { } } - function updateWorkspaceFocus() { - for (var i = 0; i < localWorkspaces.count; i++) { - const ws = localWorkspaces.get(i) - if (ws.isFocused === true) { - root.triggerUnifiedWave() - root.workspaceChanged(ws.id, Color.mPrimary) - break - } - } - } - Rectangle { id: workspaceBackground width: parent.width - Style.marginS * scaling * 2 @@ -254,8 +260,4 @@ Item { } } } - - Component.onDestruction: { - root.isDestroying = true - } } diff --git a/Modules/Calendar/Calendar.qml b/Modules/Calendar/Calendar.qml index 762c8ff..ea5b260 100644 --- a/Modules/Calendar/Calendar.qml +++ b/Modules/Calendar/Calendar.qml @@ -104,17 +104,6 @@ NPanel { year: Time.date.getFullYear() locale: Qt.locale() // Use system locale - // Optionally, update when the panel becomes visible - Connections { - target: calendarPanel - function onVisibleChanged() { - if (calendarPanel.visible) { - grid.month = Time.date.getMonth() - grid.year = Time.date.getFullYear() - } - } - } - delegate: Rectangle { width: (Style.baseWidgetSize * scaling) height: (Style.baseWidgetSize * scaling) diff --git a/Modules/IPC/IPCManager.qml b/Modules/IPC/IPCManager.qml index 8554fff..3e26628 100644 --- a/Modules/IPC/IPCManager.qml +++ b/Modules/IPC/IPCManager.qml @@ -8,7 +8,6 @@ Item { IpcHandler { target: "settings" - function toggle() { settingsPanel.toggle(Quickshell.screens[0]) } @@ -16,43 +15,64 @@ Item { IpcHandler { target: "notifications" - function toggleHistory() { notificationHistoryPanel.toggle(Quickshell.screens[0]) } - function toggleDoNotDisturb() {// TODO } } IpcHandler { target: "idleInhibitor" - function toggle() { return IdleInhibitorService.manualToggle() } } - // For backward compatibility, should be removed soon(tmc) IpcHandler { target: "appLauncher" - function toggle() { launcherPanel.toggle(Quickshell.screens[0]) } + function clipboard() { + launcherPanel.toggle(Quickshell.screens[0]) + // Use the setSearchText function to set clipboard mode + Qt.callLater(() => { + launcherPanel.setSearchText(">clip ") + }) + } + function calculator() { + launcherPanel.toggle(Quickshell.screens[0]) + // Use the setSearchText function to set calculator mode + Qt.callLater(() => { + launcherPanel.setSearchText(">calc ") + }) + } } IpcHandler { target: "launcher" - function toggle() { launcherPanel.toggle(Quickshell.screens[0]) } + function clipboard() { + launcherPanel.toggle(Quickshell.screens[0]) + // Use the setSearchText function to set clipboard mode + Qt.callLater(() => { + launcherPanel.setSearchText(">clip ") + }) + } + function calculator() { + launcherPanel.toggle(Quickshell.screens[0]) + // Use the setSearchText function to set calculator mode + Qt.callLater(() => { + launcherPanel.setSearchText(">calc ") + }) + } } IpcHandler { target: "lockScreen" - function toggle() { // Only lock if not already locked (prevents the red screen issue) // Note: No unlock via IPC for security reasons @@ -64,13 +84,25 @@ Item { IpcHandler { target: "brightness" - function increase() { BrightnessService.increaseBrightness() } - function decrease() { BrightnessService.decreaseBrightness() } } + + IpcHandler { + target: "powerPanel" + function toggle() { + powerPanel.toggle(Quickshell.screens[0]) + } + } + + IpcHandler { + target: "sidePanel" + function toggle() { + sidePanel.toggle(Quickshell.screens[0]) + } + } } diff --git a/Modules/Launcher/Calculator.qml b/Modules/Launcher/Calculator.qml index 8dae5bd..1082b89 100644 --- a/Modules/Launcher/Calculator.qml +++ b/Modules/Launcher/Calculator.qml @@ -33,7 +33,7 @@ QtObject { } } else { // Fallback to basic evaluation - console.log("AdvancedMath not available, using basic eval") + Logger.warn("Calculator", "AdvancedMath not available, using basic eval") // Basic preprocessing for common functions var processed = expression.trim( diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index 4679200..99d79cb 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -24,10 +24,30 @@ NPanel { panelAnchorLeft: launcherPosition !== "center" && (launcherPosition.endsWith("_left")) panelAnchorRight: launcherPosition !== "center" && (launcherPosition.endsWith("_right")) + // Properties + property string searchText: "" + property bool shouldResetCursor: false + + // Add function to set search text programmatically + function setSearchText(text) { + searchText = text + // The searchInput will automatically update via the text binding + // Focus and cursor position will be handled by the TextField's Component.onCompleted + } + onOpened: { // Reset state when panel opens to avoid sticky modes + if (searchText === "") { + searchText = "" + selectedIndex = 0 + } + } + + onClosed: { + // Reset search bar when launcher is closed searchText = "" selectedIndex = 0 + shouldResetCursor = true } // Import modular components @@ -50,7 +70,6 @@ NPanel { // Properties property var desktopEntries: DesktopEntries.applications.values - property string searchText: "" property int selectedIndex: 0 // Refresh clipboard when user starts typing clipboard commands @@ -141,15 +160,11 @@ NPanel { // Command execution functions function executeCalcCommand() { - searchText = ">calc " - searchInput.text = searchText - searchInput.cursorPosition = searchText.length + setSearchText(">calc ") } function executeClipCommand() { - searchText = ">clip " - searchInput.text = searchText - searchInput.cursorPosition = searchText.length + setSearchText(">clip ") } // Navigation functions @@ -252,10 +267,20 @@ NPanel { anchors.leftMargin: Style.marginS * scaling anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter + text: searchText onTextChanged: { - searchText = text + // Update the parent searchText property + if (searchText !== text) { + searchText = text + } // Defer selectedIndex reset to avoid binding loops Qt.callLater(() => selectedIndex = 0) + + // Reset cursor position if needed + if (shouldResetCursor && text === "") { + cursorPosition = 0 + shouldResetCursor = false + } } selectedTextColor: Color.mOnSurface selectionColor: Color.mPrimary @@ -266,10 +291,14 @@ NPanel { topPadding: 0 bottomPadding: 0 Component.onCompleted: { - // Focus the search bar by default + // Focus the search bar by default and set cursor position Qt.callLater(() => { selectedIndex = 0 searchInput.forceActiveFocus() + // Set cursor to end if there's already text + if (searchText && searchText.length > 0) { + searchInput.cursorPosition = searchText.length + } }) } Keys.onDownPressed: selectNext() diff --git a/Modules/LockScreen/LockContext.qml b/Modules/LockScreen/LockContext.qml new file mode 100644 index 0000000..985bcd4 --- /dev/null +++ b/Modules/LockScreen/LockContext.qml @@ -0,0 +1,94 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pam +import qs.Commons + +Scope { + id: root + signal unlocked + signal failed + + property string currentText: "" + property bool unlockInProgress: false + property bool showFailure: false + property string errorMessage: "" + property bool pamAvailable: typeof PamContext !== "undefined" + + onCurrentTextChanged: { + if (currentText !== "") { + showFailure = false + errorMessage = "" + } + } + + function tryUnlock() { + if (!pamAvailable) { + errorMessage = "PAM not available" + showFailure = true + return + } + + if (currentText === "") { + errorMessage = "Password required" + showFailure = true + return + } + + root.unlockInProgress = true + errorMessage = "" + showFailure = false + + Logger.log("LockContext", "Starting PAM authentication for user:", pam.user) + pam.start() + } + + PamContext { + id: pam + config: "login" + user: Quickshell.env("USER") + + onPamMessage: { + Logger.log("LockContext", "PAM message:", message, "isError:", messageIsError, "responseRequired:", + responseRequired) + + if (messageIsError) { + errorMessage = message + } + + if (responseRequired) { + Logger.log("LockContext", "Responding to PAM with password") + respond(root.currentText) + } + } + + onResponseRequiredChanged: { + Logger.log("LockContext", "Response required changed:", responseRequired) + if (responseRequired && root.unlockInProgress) { + Logger.log("LockContext", "Automatically responding to PAM") + respond(root.currentText) + } + } + + onCompleted: result => { + Logger.log("LockContext", "PAM completed with result:", result) + if (result === PamResult.Success) { + Logger.log("LockContext", "Authentication successful") + root.unlocked() + } else { + Logger.log("LockContext", "Authentication failed") + errorMessage = "Authentication failed" + showFailure = true + root.failed() + } + root.unlockInProgress = false + } + + onError: { + Logger.log("LockContext", "PAM error:", error, "message:", message) + errorMessage = message || "Authentication error" + showFailure = true + root.unlockInProgress = false + root.failed() + } + } +} diff --git a/Modules/LockScreen/LockScreen.qml b/Modules/LockScreen/LockScreen.qml index 5f41bd7..f155919 100644 --- a/Modules/LockScreen/LockScreen.qml +++ b/Modules/LockScreen/LockScreen.qml @@ -17,1018 +17,826 @@ Loader { id: lockScreen active: false - // Log state changes to help debug lock screen issues - onActiveChanged: { - Logger.log("LockScreen", "State changed:", active) - } - - // Allow a small grace period after unlocking so the compositor releases the lock surfaces Timer { id: unloadAfterUnlockTimer interval: 250 repeat: false onTriggered: { - Logger.log("LockScreen", "Unload timer triggered - deactivating") lockScreen.active = false } } + function scheduleUnloadAfterUnlock() { - Logger.log("LockScreen", "Scheduling unload after unlock") unloadAfterUnlockTimer.start() } + sourceComponent: Component { - WlSessionLock { - id: lock + Item { + id: lockContainer - // Tie session lock to loader visibility - locked: lockScreen.active - - property string errorMessage: "" - property bool authenticating: false - property string password: "" - property bool pamAvailable: typeof PamContext !== "undefined" - - function unlockAttempt() { - Logger.log("LockScreen", "Unlock attempt started") - - // Real PAM authentication - if (!pamAvailable) { - lock.errorMessage = "PAM authentication not available." - Logger.log("LockScreen", "PAM not available") - return + // Create the lock context + LockContext { + id: lockContext + onUnlocked: { + lockSession.locked = false + lockScreen.scheduleUnloadAfterUnlock() + lockContext.currentText = "" } - if (!lock.password) { - lock.errorMessage = "Password required." - Logger.log("LockScreen", "No password entered") - return - } - Logger.log("LockScreen", "Starting PAM authentication") - lock.authenticating = true - lock.errorMessage = "" - - Logger.log("LockScreen", "About to create PAM context with userName:", Quickshell.env("USER")) - var pam = Qt.createQmlObject( - 'import Quickshell.Services.Pam; PamContext { config: "login"; user: "' + Quickshell.env("USER") + '" }', - lock) - Logger.log("LockScreen", "PamContext created", pam) - - pam.onCompleted.connect(function (result) { - Logger.log("LockScreen", "PAM completed with result:", result) - lock.authenticating = false - if (result === PamResult.Success) { - Logger.log("LockScreen", "Authentication successful, unlocking") - // First release the Wayland session lock, then unload after a short delay - lock.locked = false - lockScreen.scheduleUnloadAfterUnlock() - lock.password = "" - lock.errorMessage = "" - } else { - Logger.log("LockScreen", "Authentication failed") - lock.errorMessage = "Authentication failed." - lock.password = "" - } - pam.destroy() - }) - - pam.onError.connect(function (error) { - Logger.log("LockScreen", "PAM error:", error) - lock.authenticating = false - lock.errorMessage = pam.message || "Authentication error." - lock.password = "" - pam.destroy() - }) - - pam.onPamMessage.connect(function () { - Logger.log("LockScreen", "PAM message:", pam.message, "isError:", pam.messageIsError) - if (pam.messageIsError) { - lock.errorMessage = pam.message - } - }) - - pam.onResponseRequiredChanged.connect(function () { - Logger.log("LockScreen", "PAM response required:", pam.responseRequired) - if (pam.responseRequired && lock.authenticating) { - Logger.log("LockScreen", "Responding to PAM with password") - pam.respond(lock.password) - } - }) - - var started = pam.start() - Logger.log("LockScreen", "PAM start result:", started) } - WlSessionLockSurface { - // Battery indicator component + WlSessionLock { + id: lockSession + locked: lockScreen.active - // WlSessionLockSurface provides a screen variable for the current screen. - // Also we use a different scaling algorithm based on the resolution, as the design is full screen. - readonly property real scaling: ScalingService.dynamicScale(screen) + WlSessionLockSurface { + readonly property real scaling: ScalingService.dynamicScale(screen) - Item { - id: batteryIndicator + Item { + id: batteryIndicator + property var battery: UPower.displayDevice + property bool isReady: battery && battery.ready && battery.isLaptopBattery && battery.isPresent + property real percent: isReady ? (battery.percentage * 100) : 0 + property bool charging: isReady ? battery.state === UPowerDeviceState.Charging : false + property bool batteryVisible: isReady && percent > 0 - // Import UPower for battery data - property var battery: UPower.displayDevice - property bool isReady: battery && battery.ready && battery.isLaptopBattery && battery.isPresent - property real percent: isReady ? (battery.percentage * 100) : 0 - property bool charging: isReady ? battery.state === UPowerDeviceState.Charging : false - property bool batteryVisible: isReady && percent > 0 - - // Choose icon based on charge and charging state - function getIcon() { - if (!batteryVisible) - return "" - - if (charging) - return "battery_android_bolt" - - if (percent >= 95) - return "battery_android_full" - - // Hardcoded battery symbols - if (percent >= 85) - return "battery_android_6" - if (percent >= 70) - return "battery_android_5" - if (percent >= 55) - return "battery_android_4" - if (percent >= 40) - return "battery_android_3" - if (percent >= 25) - return "battery_android_2" - if (percent >= 10) - return "battery_android_1" - if (percent >= 0) - return "battery_android_0" - } - } - - // Wallpaper image - Image { - id: lockBgImage - anchors.fill: parent - fillMode: Image.PreserveAspectCrop - source: WallpaperService.currentWallpaper !== "" ? WallpaperService.currentWallpaper : "" - cache: true - smooth: true - mipmap: false - } - - // Blurred background - Rectangle { - anchors.fill: parent - color: Color.transparent - - // Simple blur effect - layer.enabled: true - layer.smooth: true - layer.samples: 4 - } - - // Animated gradient overlay - Rectangle { - anchors.fill: parent - gradient: Gradient { - GradientStop { - position: 0.0 - color: Qt.rgba(0, 0, 0, 0.6) - } - GradientStop { - position: 0.3 - color: Qt.rgba(0, 0, 0, 0.3) - } - GradientStop { - position: 0.7 - color: Qt.rgba(0, 0, 0, 0.4) - } - GradientStop { - position: 1.0 - color: Qt.rgba(0, 0, 0, 0.7) + function getIcon() { + if (!batteryVisible) + return "" + if (charging) + return "battery_android_bolt" + if (percent >= 95) + return "battery_android_full" + if (percent >= 85) + return "battery_android_6" + if (percent >= 70) + return "battery_android_5" + if (percent >= 55) + return "battery_android_4" + if (percent >= 40) + return "battery_android_3" + if (percent >= 25) + return "battery_android_2" + if (percent >= 10) + return "battery_android_1" + if (percent >= 0) + return "battery_android_0" } } - // Subtle animated particles - Repeater { - model: 20 - Rectangle { - width: Math.random() * 4 + 2 - height: width - radius: width * 0.5 - color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.3) - x: Math.random() * parent.width - y: Math.random() * parent.height + Item { + id: keyboardLayout + property string currentLayout: (typeof KeyboardLayoutService !== 'undefined' + && KeyboardLayoutService.currentLayout) ? KeyboardLayoutService.currentLayout : "Unknown" + } - SequentialAnimation on opacity { - loops: Animation.Infinite - NumberAnimation { - to: 0.8 - duration: 2000 + Math.random() * 3000 - } - NumberAnimation { - to: 0.1 - duration: 2000 + Math.random() * 3000 - } + Image { + id: lockBgImage + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + source: WallpaperService.currentWallpaper !== "" ? WallpaperService.currentWallpaper : "" + cache: true + smooth: true + mipmap: false + } + + Rectangle { + anchors.fill: parent + color: Color.transparent + layer.enabled: true + layer.smooth: true + layer.samples: 4 + } + + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { + position: 0.0 + color: Qt.rgba(0, 0, 0, 0.6) + } + GradientStop { + position: 0.3 + color: Qt.rgba(0, 0, 0, 0.3) + } + GradientStop { + position: 0.7 + color: Qt.rgba(0, 0, 0, 0.4) + } + GradientStop { + position: 1.0 + color: Qt.rgba(0, 0, 0, 0.7) } } - } - } - // Main content - Centered design - Item { - anchors.fill: parent + Repeater { + model: 20 + Rectangle { + width: Math.random() * 4 + 2 + height: width + radius: width * 0.5 + color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.3) + x: Math.random() * parent.width + y: Math.random() * parent.height - // Top section - Time, date, and user info - ColumnLayout { - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: 80 * scaling - spacing: 40 * scaling - - // Time display - Large and prominent with pulse animation - Column { - spacing: Style.marginXS * scaling - Layout.alignment: Qt.AlignHCenter - - NText { - id: timeText - text: Qt.formatDateTime(new Date(), "HH:mm") - font.family: Settings.data.ui.fontBillboard - font.pointSize: Style.fontSizeXXXL * 6 * scaling - font.weight: Style.fontWeightBold - font.letterSpacing: -2 * scaling - color: Color.mOnSurface - horizontalAlignment: Text.AlignHCenter - - SequentialAnimation on scale { + SequentialAnimation on opacity { loops: Animation.Infinite NumberAnimation { - to: 1.02 - duration: 2000 - easing.type: Easing.InOutQuad + to: 0.8 + duration: 2000 + Math.random() * 3000 } NumberAnimation { - to: 1.0 - duration: 2000 - easing.type: Easing.InOutQuad + to: 0.1 + duration: 2000 + Math.random() * 3000 } } } - - NText { - id: dateText - text: Qt.formatDateTime(new Date(), "dddd, MMMM d") - font.family: Settings.data.ui.fontBillboard - font.pointSize: Style.fontSizeXXL * scaling - font.weight: Font.Light - color: Color.mOnSurface - horizontalAlignment: Text.AlignHCenter - width: timeText.width - } } + } - // User section with animated avatar - Column { - spacing: Style.marginM * scaling - Layout.alignment: Qt.AlignHCenter + Item { + anchors.fill: parent - // Animated avatar with glow effect or audio visualizer - Rectangle { - width: 108 * scaling - height: 108 * scaling - radius: width * 0.5 - color: Color.transparent - border.color: Color.mPrimary - border.width: Math.max(1, Style.borderL * scaling) - anchors.horizontalCenter: parent.horizontalCenter - z: 10 + ColumnLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 80 * scaling + spacing: 40 * scaling - // Circular audio visualizer when music is playing - Loader { - active: MediaService.isPlaying && Settings.data.audio.visualizerType == "linear" - anchors.centerIn: parent - width: 160 * scaling - height: 160 * scaling + Column { + spacing: Style.marginXS * scaling + Layout.alignment: Qt.AlignHCenter - sourceComponent: Item { - Repeater { - model: CavaService.values.length - - Rectangle { - property real linearAngle: (index / CavaService.values.length) * 2 * Math.PI - property real linearRadius: 70 * scaling - property real linearBarLength: Math.max(2, CavaService.values[index] * 30 * scaling) - property real linearBarWidth: 3 * scaling - - width: linearBarWidth - height: linearBarLength - color: Color.mPrimary - radius: linearBarWidth * 0.5 - - x: parent.width * 0.5 + Math.cos(linearAngle) * linearRadius - width * 0.5 - y: parent.height * 0.5 + Math.sin(linearAngle) * linearRadius - height * 0.5 - - transform: Rotation { - origin.x: linearBarWidth * 0.5 - origin.y: linearBarLength * 0.5 - angle: (linearAngle * 180 / Math.PI) + 90 - } - } - } - } - } - - Loader { - active: MediaService.isPlaying && Settings.data.audio.visualizerType == "mirrored" - anchors.centerIn: parent - width: 160 * scaling - height: 160 * scaling - - sourceComponent: Item { - Repeater { - model: CavaService.values.length * 2 - - Rectangle { - property int mirroredValueIndex: index < CavaService.values.length ? index : (CavaService.values.length - * 2 - 1 - index) - property real mirroredAngle: (index / (CavaService.values.length * 2)) * 2 * Math.PI - property real mirroredRadius: 70 * scaling - property real mirroredBarLength: Math.max(2, - CavaService.values[mirroredValueIndex] * 30 * scaling) - property real mirroredBarWidth: 3 * scaling - - width: mirroredBarWidth - height: mirroredBarLength - color: Color.mPrimary - radius: mirroredBarWidth * 0.5 - - x: parent.width * 0.5 + Math.cos(mirroredAngle) * mirroredRadius - width * 0.5 - y: parent.height * 0.5 + Math.sin(mirroredAngle) * mirroredRadius - height * 0.5 - - transform: Rotation { - origin.x: mirroredBarWidth * 0.5 - origin.y: mirroredBarLength * 0.5 - angle: (mirroredAngle * 180 / Math.PI) + 90 - } - } - } - } - } - - Loader { - active: MediaService.isPlaying && Settings.data.audio.visualizerType == "wave" - anchors.centerIn: parent - width: 160 * scaling - height: 160 * scaling - - sourceComponent: Item { - Canvas { - id: waveCanvas - anchors.fill: parent - antialiasing: true - - onPaint: { - var ctx = getContext("2d") - ctx.reset() - - if (CavaService.values.length === 0) { - return - } - - ctx.strokeStyle = Color.mPrimary - ctx.lineWidth = 2 * scaling - ctx.lineCap = "round" - - var centerX = width * 0.5 - var centerY = height * 0.5 - var baseRadius = 60 * scaling - var maxAmplitude = 20 * scaling - - ctx.beginPath() - - for (var i = 0; i <= CavaService.values.length; i++) { - var index = i % CavaService.values.length - var angle = (i / CavaService.values.length) * 2 * Math.PI - var amplitude = CavaService.values[index] * maxAmplitude - var radius = baseRadius + amplitude - - var x = centerX + Math.cos(angle) * radius - var y = centerY + Math.sin(angle) * radius - - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } - } - - ctx.closePath() - ctx.stroke() - } - } - - Timer { - interval: 16 // ~60 FPS - running: true - repeat: true - onTriggered: { - waveCanvas.requestPaint() - } - } - } - } - - // Glow effect when no music is playing - Rectangle { - anchors.centerIn: parent - width: parent.width + 24 * scaling - height: parent.height + 24 * scaling - radius: width * 0.5 - color: Color.transparent - border.color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.3) - border.width: Math.max(1, Style.borderM * scaling) - z: -1 - visible: !MediaService.isPlaying + NText { + id: timeText + text: Qt.formatDateTime(new Date(), "HH:mm") + font.family: Settings.data.ui.fontBillboard + font.pointSize: Style.fontSizeXXXL * 6 * scaling + font.weight: Style.fontWeightBold + font.letterSpacing: -2 * scaling + color: Color.mOnSurface + horizontalAlignment: Text.AlignHCenter SequentialAnimation on scale { loops: Animation.Infinite NumberAnimation { - to: 1.1 - duration: 1500 + to: 1.02 + duration: 2000 easing.type: Easing.InOutQuad } NumberAnimation { to: 1.0 - duration: 1500 + duration: 2000 easing.type: Easing.InOutQuad } } } - NImageCircled { - anchors.centerIn: parent - width: 100 * scaling - height: 100 * scaling - imagePath: Settings.data.general.avatarImage - fallbackIcon: "person" + NText { + id: dateText + text: Qt.formatDateTime(new Date(), "dddd, MMMM d") + font.family: Settings.data.ui.fontBillboard + font.pointSize: Style.fontSizeXXL * scaling + font.weight: Font.Light + color: Color.mOnSurface + horizontalAlignment: Text.AlignHCenter + width: timeText.width } + } - // Hover animation - MouseArea { - anchors.fill: parent - hoverEnabled: true - onEntered: parent.scale = 1.05 - onExited: parent.scale = 1.0 - } + Column { + spacing: Style.marginM * scaling + Layout.alignment: Qt.AlignHCenter - Behavior on scale { - NumberAnimation { - duration: Style.animationFast - easing.type: Easing.OutBack + Rectangle { + width: 108 * scaling + height: 108 * scaling + radius: width * 0.5 + color: Color.transparent + border.color: Color.mPrimary + border.width: Math.max(1, Style.borderL * scaling) + anchors.horizontalCenter: parent.horizontalCenter + z: 10 + + Loader { + active: MediaService.isPlaying && Settings.data.audio.visualizerType == "linear" + anchors.centerIn: parent + width: 160 * scaling + height: 160 * scaling + sourceComponent: Item { + Repeater { + model: CavaService.values.length + Rectangle { + property real linearAngle: (index / CavaService.values.length) * 2 * Math.PI + property real linearRadius: 70 * scaling + property real linearBarLength: Math.max(2, CavaService.values[index] * 30 * scaling) + property real linearBarWidth: 3 * scaling + width: linearBarWidth + height: linearBarLength + color: Color.mPrimary + radius: linearBarWidth * 0.5 + x: parent.width * 0.5 + Math.cos(linearAngle) * linearRadius - width * 0.5 + y: parent.height * 0.5 + Math.sin(linearAngle) * linearRadius - height * 0.5 + transform: Rotation { + origin.x: linearBarWidth * 0.5 + origin.y: linearBarLength * 0.5 + angle: (linearAngle * 180 / Math.PI) + 90 + } + } + } + } + } + + Loader { + active: MediaService.isPlaying && Settings.data.audio.visualizerType == "mirrored" + anchors.centerIn: parent + width: 160 * scaling + height: 160 * scaling + sourceComponent: Item { + Repeater { + model: CavaService.values.length * 2 + Rectangle { + property int mirroredValueIndex: index < CavaService.values.length ? index : (CavaService.values.length + * 2 - 1 - index) + property real mirroredAngle: (index / (CavaService.values.length * 2)) * 2 * Math.PI + property real mirroredRadius: 70 * scaling + property real mirroredBarLength: Math.max( + 2, CavaService.values[mirroredValueIndex] * 30 * scaling) + property real mirroredBarWidth: 3 * scaling + width: mirroredBarWidth + height: mirroredBarLength + color: Color.mPrimary + radius: mirroredBarWidth * 0.5 + x: parent.width * 0.5 + Math.cos(mirroredAngle) * mirroredRadius - width * 0.5 + y: parent.height * 0.5 + Math.sin(mirroredAngle) * mirroredRadius - height * 0.5 + transform: Rotation { + origin.x: mirroredBarWidth * 0.5 + origin.y: mirroredBarLength * 0.5 + angle: (mirroredAngle * 180 / Math.PI) + 90 + } + } + } + } + } + + Loader { + active: MediaService.isPlaying && Settings.data.audio.visualizerType == "wave" + anchors.centerIn: parent + width: 160 * scaling + height: 160 * scaling + sourceComponent: Item { + Canvas { + id: waveCanvas + anchors.fill: parent + antialiasing: true + onPaint: { + var ctx = getContext("2d") + ctx.reset() + if (CavaService.values.length === 0) + return + ctx.strokeStyle = Color.mPrimary + ctx.lineWidth = 2 * scaling + ctx.lineCap = "round" + var centerX = width * 0.5 + var centerY = height * 0.5 + var baseRadius = 60 * scaling + var maxAmplitude = 20 * scaling + ctx.beginPath() + for (var i = 0; i <= CavaService.values.length; i++) { + var index = i % CavaService.values.length + var angle = (i / CavaService.values.length) * 2 * Math.PI + var amplitude = CavaService.values[index] * maxAmplitude + var radius = baseRadius + amplitude + var x = centerX + Math.cos(angle) * radius + var y = centerY + Math.sin(angle) * radius + if (i === 0) + ctx.moveTo(x, y) + else + ctx.lineTo(x, y) + } + ctx.closePath() + ctx.stroke() + } + } + Timer { + interval: 16 + running: true + repeat: true + onTriggered: waveCanvas.requestPaint() + } + } + } + + Rectangle { + anchors.centerIn: parent + width: parent.width + 24 * scaling + height: parent.height + 24 * scaling + radius: width * 0.5 + color: Color.transparent + border.color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.3) + border.width: Math.max(1, Style.borderM * scaling) + z: -1 + visible: !MediaService.isPlaying + SequentialAnimation on scale { + loops: Animation.Infinite + NumberAnimation { + to: 1.1 + duration: 1500 + easing.type: Easing.InOutQuad + } + NumberAnimation { + to: 1.0 + duration: 1500 + easing.type: Easing.InOutQuad + } + } + } + + NImageCircled { + anchors.centerIn: parent + width: 100 * scaling + height: 100 * scaling + imagePath: Settings.data.general.avatarImage + fallbackIcon: "person" + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: parent.scale = 1.05 + onExited: parent.scale = 1.0 + } + + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } } } } } - } - // Centered terminal section - Item { - width: 720 * scaling - height: 280 * scaling - anchors.centerIn: parent - - // Futuristic Terminal-Style Input Item { - width: parent.width + width: 720 * scaling height: 280 * scaling - Layout.fillWidth: true + anchors.centerIn: parent + anchors.verticalCenterOffset: 50 * scaling - // Terminal background with scanlines - Rectangle { - id: terminalBackground - anchors.fill: parent - radius: Style.radiusM * scaling - color: Color.applyOpacity(Color.mSurface, "E6") - border.color: Color.mPrimary - border.width: Math.max(1, Style.borderM * scaling) + Item { + width: parent.width + height: 280 * scaling + Layout.fillWidth: true + + Rectangle { + id: terminalBackground + anchors.fill: parent + radius: Style.radiusM * scaling + color: Color.applyOpacity(Color.mSurface, "E6") + border.color: Color.mPrimary + border.width: Math.max(1, Style.borderM * scaling) + + Repeater { + model: 20 + Rectangle { + width: parent.width + height: 1 + color: Color.applyOpacity(Color.mPrimary, "1A") + y: index * 10 * scaling + opacity: Style.opacityMedium + SequentialAnimation on opacity { + loops: Animation.Infinite + NumberAnimation { + to: 0.6 + duration: 2000 + Math.random() * 1000 + } + NumberAnimation { + to: 0.1 + duration: 2000 + Math.random() * 1000 + } + } + } + } - // Scanline effect - Repeater { - model: 20 Rectangle { width: parent.width - height: 1 - color: Color.applyOpacity(Color.mPrimary, "1A") - y: index * 10 * scaling - opacity: Style.opacityMedium + height: 40 * scaling + color: Color.applyOpacity(Color.mPrimary, "33") + topLeftRadius: Style.radiusS * scaling + topRightRadius: Style.radiusS * scaling + + RowLayout { + anchors.fill: parent + anchors.topMargin: Style.marginM * scaling + anchors.bottomMargin: Style.marginM * scaling + anchors.leftMargin: Style.marginL * scaling + anchors.rightMargin: Style.marginL * scaling + spacing: Style.marginM * scaling + + NText { + text: "SECURE TERMINAL" + color: Color.mOnSurface + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeL * scaling + font.weight: Style.fontWeightBold + Layout.fillWidth: true + } + + Row { + spacing: Style.marginS * scaling + visible: batteryIndicator.batteryVisible + NIcon { + text: batteryIndicator.getIcon() + font.pointSize: Style.fontSizeM * scaling + color: batteryIndicator.charging ? Color.mPrimary : Color.mOnSurface + } + NText { + text: Math.round(batteryIndicator.percent) + "%" + color: Color.mOnSurface + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeM * scaling + font.weight: Style.fontWeightBold + } + } + + Row { + spacing: Style.marginS * scaling + NText { + text: keyboardLayout.currentLayout + color: Color.mOnSurface + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeM * scaling + font.weight: Style.fontWeightBold + } + NIcon { + text: "keyboard_alt" + font.pointSize: Style.fontSizeM * scaling + color: Color.mOnSurface + } + } + } + } + + ColumnLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Style.marginL * scaling + anchors.topMargin: 70 * scaling + spacing: Style.marginM * scaling + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + NText { + text: Quickshell.env("USER") + "@noctalia:~$" + color: Color.mPrimary + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeL * scaling + font.weight: Style.fontWeightBold + } + + NText { + id: welcomeText + text: "" + color: Color.mOnSurface + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeL * scaling + property int currentIndex: 0 + property string fullText: "Welcome back, " + Quickshell.env("USER") + "!" + + Timer { + interval: Style.animationFast + running: true + repeat: true + onTriggered: { + if (parent.currentIndex < parent.fullText.length) { + parent.text = parent.fullText.substring(0, parent.currentIndex + 1) + parent.currentIndex++ + } else { + running = false + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + NText { + text: Quickshell.env("USER") + "@noctalia:~$" + color: Color.mPrimary + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeL * scaling + font.weight: Style.fontWeightBold + } + + NText { + text: "sudo unlock-session" + color: Color.mOnSurface + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeL * scaling + } + + TextInput { + id: passwordInput + width: 0 + height: 0 + visible: false + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeL * scaling + color: Color.mOnSurface + echoMode: TextInput.Password + passwordCharacter: "*" + passwordMaskDelay: 0 + + text: lockContext.currentText + onTextChanged: { + lockContext.currentText = text + } + + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + lockContext.tryUnlock() + } + } + + Component.onCompleted: { + forceActiveFocus() + } + } + + NText { + id: asterisksText + text: "*".repeat(passwordInput.text.length) + color: Color.mOnSurface + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeL * scaling + visible: passwordInput.activeFocus + + SequentialAnimation { + id: typingEffect + NumberAnimation { + target: passwordInput + property: "scale" + to: 1.01 + duration: 50 + } + NumberAnimation { + target: passwordInput + property: "scale" + to: 1.0 + duration: 50 + } + } + } + + Rectangle { + width: 8 * scaling + height: 20 * scaling + color: Color.mPrimary + visible: passwordInput.activeFocus + Layout.leftMargin: -Style.marginS * scaling + Layout.alignment: Qt.AlignVCenter + + SequentialAnimation on opacity { + loops: Animation.Infinite + NumberAnimation { + to: 1.0 + duration: 500 + } + NumberAnimation { + to: 0.0 + duration: 500 + } + } + } + } + + NText { + text: { + if (lockContext.unlockInProgress) + return "Authenticating..." + if (lockContext.showFailure && lockContext.errorMessage) + return lockContext.errorMessage + if (lockContext.showFailure) + return "Authentication failed." + return "" + } + color: { + if (lockContext.unlockInProgress) + return Color.mPrimary + if (lockContext.showFailure) + return Color.mError + return Color.transparent + } + font.family: "DejaVu Sans Mono" + font.pointSize: Style.fontSizeL * scaling + Layout.fillWidth: true + + SequentialAnimation on opacity { + running: lockContext.unlockInProgress + loops: Animation.Infinite + NumberAnimation { + to: 1.0 + duration: 800 + } + NumberAnimation { + to: 0.5 + duration: 800 + } + } + } + + Row { + Layout.alignment: Qt.AlignRight + Layout.bottomMargin: -10 * scaling + Rectangle { + width: 120 * scaling + height: 40 * scaling + radius: Style.radiusS * scaling + color: executeButtonArea.containsMouse ? Color.mPrimary : Color.applyOpacity(Color.mPrimary, + "33") + border.color: Color.mPrimary + border.width: Math.max(1, Style.borderS * scaling) + enabled: !lockContext.unlockInProgress + + NText { + anchors.centerIn: parent + text: lockContext.unlockInProgress ? "EXECUTING" : "EXECUTE" + color: executeButtonArea.containsMouse ? Color.mOnPrimary : Color.mPrimary + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeM * scaling + font.weight: Style.fontWeightBold + } + + MouseArea { + id: executeButtonArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + lockContext.tryUnlock() + } + + SequentialAnimation on scale { + running: executeButtonArea.containsMouse + NumberAnimation { + to: 1.05 + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + + SequentialAnimation on scale { + running: !executeButtonArea.containsMouse + NumberAnimation { + to: 1.0 + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + } + + SequentialAnimation on scale { + loops: Animation.Infinite + running: lockContext.unlockInProgress + NumberAnimation { + to: 1.02 + duration: 600 + easing.type: Easing.InOutQuad + } + NumberAnimation { + to: 1.0 + duration: 600 + easing.type: Easing.InOutQuad + } + } + } + } + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: Color.transparent + border.color: Color.applyOpacity(Color.mPrimary, "4D") + border.width: Math.max(1, Style.borderS * scaling) + z: -1 SequentialAnimation on opacity { loops: Animation.Infinite NumberAnimation { to: 0.6 - duration: 2000 + Math.random() * 1000 + duration: 2000 + easing.type: Easing.InOutQuad } NumberAnimation { - to: 0.1 - duration: 2000 + Math.random() * 1000 + to: 0.2 + duration: 2000 + easing.type: Easing.InOutQuad } } } } + } + } - // Terminal header - Rectangle { - width: parent.width - height: 40 * scaling - color: Color.applyOpacity(Color.mPrimary, "33") - topLeftRadius: Style.radiusS * scaling - topRightRadius: Style.radiusS * scaling + // Power buttons at bottom + Row { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 50 * scaling + spacing: 20 * scaling - RowLayout { - anchors.fill: parent - anchors.topMargin: Style.marginM * scaling - anchors.bottomMargin: Style.marginM * scaling - anchors.leftMargin: Style.marginL * scaling - anchors.rightMargin: Style.marginL * scaling - spacing: Style.marginM * scaling + Rectangle { + width: 60 * scaling + height: 60 * scaling + radius: width * 0.5 + color: powerButtonArea.containsMouse ? Color.mError : Color.applyOpacity(Color.mError, "33") + border.color: Color.mError + border.width: Math.max(1, Style.borderM * scaling) - NText { - text: "SECURE TERMINAL" - color: Color.mOnSurface - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeL * scaling - font.weight: Style.fontWeightBold - Layout.fillWidth: true - } - - // Battery indicator - Row { - spacing: Style.marginS * scaling - visible: batteryIndicator.batteryVisible - - NIcon { - text: batteryIndicator.getIcon() - font.pointSize: Style.fontSizeM * scaling - color: batteryIndicator.charging ? Color.mPrimary : Color.mOnSurface - } - - NText { - text: Math.round(batteryIndicator.percent) + "%" - color: Color.mOnSurface - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeM * scaling - font.weight: Style.fontWeightBold - } - } - } + NIcon { + anchors.centerIn: parent + text: "power_settings_new" + font.pointSize: Style.fontSizeXL * scaling + color: powerButtonArea.containsMouse ? Color.mOnError : Color.mError } - // Terminal content area - ColumnLayout { - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.margins: Style.marginL * scaling - anchors.topMargin: 70 * scaling - spacing: Style.marginM * scaling - - // Welcome back typing effect - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM * scaling - - NText { - text: "root@noctalia:~$" - color: Color.mPrimary - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeL * scaling - font.weight: Style.fontWeightBold - } - - NText { - id: welcomeText - text: "" - color: Color.mOnSurface - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeL * scaling - property int currentIndex: 0 - property string fullText: "Welcome back, " + Quickshell.env("USER") + "!" - - Timer { - interval: Style.animationFast - running: true - repeat: true - onTriggered: { - if (parent.currentIndex < parent.fullText.length) { - parent.text = parent.fullText.substring(0, parent.currentIndex + 1) - parent.currentIndex++ - } else { - running = false - } - } - } - } - } - - // Command line with integrated password input - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM * scaling - - NText { - text: "root@noctalia:~$" - color: Color.mPrimary - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeL * scaling - font.weight: Style.fontWeightBold - } - - NText { - text: "sudo unlock-session" - color: Color.mOnSurface - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeL * scaling - } - - // Integrated password input (invisible, just for functionality) - TextInput { - id: passwordInput - width: 0 - height: 0 - visible: false - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeL * scaling - color: Color.mOnSurface - echoMode: TextInput.Password - passwordCharacter: "*" - passwordMaskDelay: 0 - - text: lock.password - onTextChanged: { - lock.password = text - // Terminal typing sound effect (visual) - typingEffect.start() - } - - Keys.onPressed: function (event) { - if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - lock.unlockAttempt() - } - } - - Component.onCompleted: { - forceActiveFocus() - } - } - - // Visual password display with integrated cursor - NText { - id: asterisksText - text: "*".repeat(passwordInput.text.length) - color: Color.mOnSurface - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeL * scaling - visible: passwordInput.activeFocus - - // Typing effect animation - SequentialAnimation { - id: typingEffect - NumberAnimation { - target: passwordInput - property: "scale" - to: 1.01 - duration: 50 - } - NumberAnimation { - target: passwordInput - property: "scale" - to: 1.0 - duration: 50 - } - } - } - - // Blinking cursor positioned right after the asterisks - Rectangle { - width: 8 * scaling - height: 20 * scaling - color: Color.mPrimary - visible: passwordInput.activeFocus - Layout.leftMargin: -Style.marginS * scaling - Layout.alignment: Qt.AlignVCenter - - SequentialAnimation on opacity { - loops: Animation.Infinite - NumberAnimation { - to: 1.0 - duration: 500 - } - NumberAnimation { - to: 0.0 - duration: 500 - } - } - } - } - - // Status messages - NText { - text: lock.authenticating ? "Authenticating..." : (lock.errorMessage !== "" ? "Authentication failed." : "") - color: lock.authenticating ? Color.mPrimary : (lock.errorMessage !== "" ? Color.mError : Color.transparent) - font.family: "DejaVu Sans Mono" - font.pointSize: Style.fontSizeL * scaling - Layout.fillWidth: true - - SequentialAnimation on opacity { - running: lock.authenticating - loops: Animation.Infinite - NumberAnimation { - to: 1.0 - duration: 800 - } - NumberAnimation { - to: 0.5 - duration: 800 - } - } - } - - // Execute button - Row { - Layout.alignment: Qt.AlignRight - Layout.bottomMargin: -10 * scaling - Rectangle { - width: 120 * scaling - height: 40 * scaling - radius: Style.radiusS * scaling - color: executeButtonArea.containsMouse ? Color.mPrimary : Color.applyOpacity(Color.mPrimary, "33") - border.color: Color.mPrimary - border.width: Math.max(1, Style.borderS * scaling) - enabled: !lock.authenticating - - NText { - anchors.centerIn: parent - text: lock.authenticating ? "EXECUTING" : "EXECUTE" - color: executeButtonArea.containsMouse ? Color.mOnPrimary : Color.mPrimary - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeM * scaling - font.weight: Style.fontWeightBold - } - - MouseArea { - id: executeButtonArea - anchors.fill: parent - hoverEnabled: true - onClicked: lock.unlockAttempt() - - SequentialAnimation on scale { - running: executeButtonArea.containsMouse - NumberAnimation { - to: 1.05 - duration: Style.animationFast - easing.type: Easing.OutCubic - } - } - - SequentialAnimation on scale { - running: !executeButtonArea.containsMouse - NumberAnimation { - to: 1.0 - duration: Style.animationFast - easing.type: Easing.OutCubic - } - } - } - - // Processing animation - SequentialAnimation on scale { - loops: Animation.Infinite - running: lock.authenticating - NumberAnimation { - to: 1.02 - duration: 600 - easing.type: Easing.InOutQuad - } - NumberAnimation { - to: 1.0 - duration: 600 - easing.type: Easing.InOutQuad - } - } - } - } - } - - // Terminal glow effect - Rectangle { + MouseArea { + id: powerButtonArea anchors.fill: parent - radius: parent.radius - color: Color.transparent - border.color: Color.applyOpacity(Color.mPrimary, "4D") - border.width: Math.max(1, Style.borderS * scaling) - z: -1 + hoverEnabled: true + onClicked: { + CompositorService.shutdown() + } + } + } - SequentialAnimation on opacity { - loops: Animation.Infinite - NumberAnimation { - to: 0.6 - duration: 2000 - easing.type: Easing.InOutQuad - } - NumberAnimation { - to: 0.2 - duration: 2000 - easing.type: Easing.InOutQuad - } + Rectangle { + width: 60 * scaling + height: 60 * scaling + radius: width * 0.5 + color: restartButtonArea.containsMouse ? Color.mPrimary : Color.applyOpacity(Color.mPrimary, "33") + border.color: Color.mPrimary + border.width: Math.max(1, Style.borderM * scaling) + + NIcon { + anchors.centerIn: parent + text: "restart_alt" + font.pointSize: Style.fontSizeXL * scaling + color: restartButtonArea.containsMouse ? Color.mOnPrimary : Color.mPrimary + } + + MouseArea { + id: restartButtonArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + CompositorService.reboot() + } + } + } + + Rectangle { + width: 60 * scaling + height: 60 * scaling + radius: width * 0.5 + color: suspendButtonArea.containsMouse ? Color.mSecondary : Color.applyOpacity(Color.mSecondary, "33") + border.color: Color.mSecondary + border.width: Math.max(1, Style.borderM * scaling) + + NIcon { + anchors.centerIn: parent + text: "bedtime" + font.pointSize: Style.fontSizeXL * scaling + color: suspendButtonArea.containsMouse ? Color.mOnSecondary : Color.mSecondary + } + + MouseArea { + id: suspendButtonArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + CompositorService.suspend() } } } } } - } - // Enhanced power buttons with hover effects - Row { - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.margins: 50 * scaling - spacing: 20 * scaling - - // Shutdown with enhanced styling - Rectangle { - width: 64 * scaling - height: 64 * scaling - radius: Style.radiusL * scaling - color: shutdownArea.containsMouse ? Color.applyOpacity(Color.mError, - "DD") : Color.applyOpacity(Color.mError, "22") - border.color: Color.mError - border.width: Math.max(1, Style.borderM * scaling) - - // Glow effect - Rectangle { - anchors.centerIn: parent - width: parent.width + 10 * scaling - height: parent.height + 10 * scaling - radius: width * 0.5 - color: Color.transparent - opacity: shutdownArea.containsMouse ? 1 : 0 - z: -1 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - } + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: { + timeText.text = Qt.formatDateTime(new Date(), "HH:mm") + dateText.text = Qt.formatDateTime(new Date(), "dddd, MMMM d") } - - MouseArea { - id: shutdownArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - Qt.createQmlObject( - 'import Quickshell.Io; Process { command: ["shutdown", "-h", "now"]; running: true }', lock) - } - } - - NIcon { - text: "power_settings_new" - font.pointSize: Style.fontSizeXXXL * scaling - color: shutdownArea.containsMouse ? Color.mOnPrimary : Color.mError - anchors.centerIn: parent - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - } - scale: shutdownArea.containsMouse ? 1.1 : 1.0 - } - - // Reboot with enhanced styling - Rectangle { - width: 64 * scaling - height: 64 * scaling - radius: Style.radiusL * scaling - color: rebootArea.containsMouse ? Color.applyOpacity(Color.mPrimary, - "DD") : Color.applyOpacity(Color.mPrimary, "22") - border.color: Color.mPrimary - border.width: Math.max(1, Style.borderM * scaling) - - // Glow effect - Rectangle { - anchors.centerIn: parent - width: parent.width + 10 * scaling - height: parent.height + 10 * scaling - radius: width * 0.5 - color: Color.transparent - opacity: rebootArea.containsMouse ? 1 : 0 - z: -1 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationMedium - easing.type: Easing.OutCubic - } - } - } - - MouseArea { - id: rebootArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - Qt.createQmlObject('import Quickshell.Io; Process { command: ["reboot"]; running: true }', lock) - } - } - - NIcon { - text: "refresh" - font.pointSize: Style.fontSizeXXXL * scaling - color: rebootArea.containsMouse ? Color.mOnPrimary : Color.mPrimary - anchors.centerIn: parent - } - - Behavior on color { - ColorAnimation { - duration: Style.animationMedium - easing.type: Easing.OutCubic - } - } - scale: rebootArea.containsMouse ? 1.1 : 1.0 - } - - // Logout with enhanced styling - Rectangle { - width: 64 * scaling - height: 64 * scaling - radius: Style.radiusL * scaling - color: logoutArea.containsMouse ? Color.applyOpacity(Color.mSecondary, - "DD") : Color.applyOpacity(Color.mSecondary, "22") - border.color: Color.mSecondary - border.width: Math.max(1, Style.borderM * scaling) - - // Glow effect - Rectangle { - anchors.centerIn: parent - width: parent.width + 10 * scaling - height: parent.height + 10 * scaling - radius: width * 0.5 - color: Color.transparent - opacity: logoutArea.containsMouse ? 1 : 0 - z: -1 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationMedium - easing.type: Easing.OutCubic - } - } - } - - MouseArea { - id: logoutArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - Qt.createQmlObject( - 'import Quickshell.Io; Process { command: ["loginctl", "terminate-user", "' + Quickshell.env( - "USER") + '"]; running: true }', lock) - } - } - - NIcon { - text: "exit_to_app" - font.pointSize: Style.fontSizeXXXL * scaling - color: logoutArea.containsMouse ? Color.mOnPrimary : Color.mSecondary - anchors.centerIn: parent - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - } - scale: logoutArea.containsMouse ? 1.1 : 1.0 - } - } - - // Timer for updating time - Timer { - interval: 1000 - running: true - repeat: true - onTriggered: { - timeText.text = Qt.formatDateTime(new Date(), "HH:mm") - dateText.text = Qt.formatDateTime(new Date(), "dddd, MMMM d") } } } diff --git a/Modules/SettingsPanel/Tabs/AudioTab.qml b/Modules/SettingsPanel/Tabs/AudioTab.qml index a644a47..065350f 100644 --- a/Modules/SettingsPanel/Tabs/AudioTab.qml +++ b/Modules/SettingsPanel/Tabs/AudioTab.qml @@ -308,6 +308,45 @@ ColumnLayout { Settings.data.audio.visualizerType = key } } + + NComboBox { + label: "Frame Rate" + description: "Target frame rate for audio visualizer. (default: 60)" + model: ListModel { + ListElement { + key: "30" + name: "30 FPS" + } + ListElement { + key: "60" + name: "60 FPS" + } + ListElement { + key: "100" + name: "100 FPS" + } + ListElement { + key: "120" + name: "120 FPS" + } + ListElement { + key: "144" + name: "144 FPS" + } + ListElement { + key: "165" + name: "165 FPS" + } + ListElement { + key: "240" + name: "240 FPS" + } + } + currentKey: Settings.data.audio.cavaFrameRate + onSelected: key => { + Settings.data.audio.cavaFrameRate = key + } + } } } } diff --git a/Modules/SettingsPanel/Tabs/BarTab.qml b/Modules/SettingsPanel/Tabs/BarTab.qml index 9e4bf9d..efee796 100644 --- a/Modules/SettingsPanel/Tabs/BarTab.qml +++ b/Modules/SettingsPanel/Tabs/BarTab.qml @@ -33,7 +33,6 @@ ColumnLayout { spacing: Style.marginL * scaling Layout.fillWidth: true - ColumnLayout { spacing: Style.marginXXS * scaling Layout.fillWidth: true @@ -72,7 +71,7 @@ ColumnLayout { } } - ColumnLayout { + ColumnLayout { spacing: Style.marginXXS * scaling Layout.fillWidth: true @@ -111,7 +110,6 @@ ColumnLayout { } } - NToggle { label: "Show Active Window's Icon" description: "Display the app icon next to the title of the currently focused window." @@ -130,7 +128,6 @@ ColumnLayout { } } - NDivider { Layout.fillWidth: true Layout.topMargin: Style.marginL * scaling @@ -144,13 +141,14 @@ ColumnLayout { NText { text: "Widgets Positioning" - font.pointSize: Style.fontSizeL * scaling + font.pointSize: Style.fontSizeXXL * scaling font.weight: Style.fontWeightBold color: Color.mOnSurface + Layout.bottomMargin: Style.marginS * scaling } NText { - text: "Add, remove, or reorder widgets in each section of the bar using the control buttons." + text: "Drag and drop widgets to reorder them within each section, or use the add/remove buttons to manage widgets." font.pointSize: Style.fontSizeXS * scaling color: Color.mOnSurfaceVariant wrapMode: Text.WordWrap @@ -165,7 +163,7 @@ ColumnLayout { spacing: Style.marginM * scaling // Left Section - NWidgetCard { + NSectionEditor { sectionName: "Left" widgetModel: Settings.data.bar.widgets.left availableWidgets: availableWidgets @@ -176,7 +174,7 @@ ColumnLayout { } // Center Section - NWidgetCard { + NSectionEditor { sectionName: "Center" widgetModel: Settings.data.bar.widgets.center availableWidgets: availableWidgets @@ -187,7 +185,7 @@ ColumnLayout { } // Right Section - NWidgetCard { + NSectionEditor { sectionName: "Right" widgetModel: Settings.data.bar.widgets.right availableWidgets: availableWidgets @@ -204,13 +202,13 @@ ColumnLayout { // Helper functions function addWidgetToSection(widgetName, section) { - console.log("Adding widget", widgetName, "to section", section) + //Logger.log("BarTab", "Adding widget", widgetName, "to section", section) var sectionArray = Settings.data.bar.widgets[section] if (sectionArray) { // Create a new array to avoid modifying the original var newArray = sectionArray.slice() newArray.push(widgetName) - console.log("Widget added. New array:", JSON.stringify(newArray)) + //Logger.log("BarTab", "Widget added. New array:", JSON.stringify(newArray)) // Assign the new array Settings.data.bar.widgets[section] = newArray @@ -218,21 +216,27 @@ ColumnLayout { } function removeWidgetFromSection(section, index) { - console.log("Removing widget from section", section, "at index", index) + // Logger.log("BarTab", "Removing widget from section", section, "at index", index) var sectionArray = Settings.data.bar.widgets[section] + + //Logger.log("BarTab", "Current section array:", JSON.stringify(sectionArray)) if (sectionArray && index >= 0 && index < sectionArray.length) { // Create a new array to avoid modifying the original var newArray = sectionArray.slice() newArray.splice(index, 1) - console.log("Widget removed. New array:", JSON.stringify(newArray)) + //Logger.log("BarTab", "Widget removed. New array:", JSON.stringify(newArray)) // Assign the new array Settings.data.bar.widgets[section] = newArray + } else { + + //Logger.log("BarTab", "Invalid section or index:", section, index, "array length:", + // sectionArray ? sectionArray.length : "null") } } function reorderWidgetInSection(section, fromIndex, toIndex) { - console.log("Reordering widget in section", section, "from", fromIndex, "to", toIndex) + //Logger.log("BarTab", "Reordering widget in section", section, "from", fromIndex, "to", toIndex) var sectionArray = Settings.data.bar.widgets[section] if (sectionArray && fromIndex >= 0 && fromIndex < sectionArray.length && toIndex >= 0 && toIndex < sectionArray.length) { @@ -242,36 +246,26 @@ ColumnLayout { var item = newArray[fromIndex] newArray.splice(fromIndex, 1) newArray.splice(toIndex, 0, item) - console.log("Widget reordered. New array:", JSON.stringify(newArray)) + Logger.log("BarTab", "Widget reordered. New array:", JSON.stringify(newArray)) // Assign the new array Settings.data.bar.widgets[section] = newArray } } - // Widget loader for discovering available widgets - WidgetLoader { - id: widgetLoader - } - + // Base list model for all combo boxes ListModel { id: availableWidgets } Component.onCompleted: { - discoverWidgets() - } - - // Automatically discover available widgets using WidgetLoader - function discoverWidgets() { + // Fill out availableWidgets ListModel availableWidgets.clear() - - // Use WidgetLoader to discover available widgets - const discoveredWidgets = widgetLoader.discoverAvailableWidgets() - - // Add discovered widgets to the ListModel - discoveredWidgets.forEach(widget => { - availableWidgets.append(widget) - }) + BarWidgetRegistry.getAvailableWidgets().forEach(entry => { + availableWidgets.append({ + "key": entry, + "name": entry + }) + }) } } diff --git a/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml b/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml index 836ca6c..de91242 100644 --- a/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml +++ b/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml @@ -125,10 +125,22 @@ ColumnLayout { key: "60" name: "60 FPS" } + ListElement { + key: "100" + name: "100 FPS" + } ListElement { key: "120" name: "120 FPS" } + ListElement { + key: "144" + name: "144 FPS" + } + ListElement { + key: "165" + name: "165 FPS" + } ListElement { key: "240" name: "240 FPS" diff --git a/Modules/SidePanel/Cards/MediaCard.qml b/Modules/SidePanel/Cards/MediaCard.qml index aad0839..fae3f38 100644 --- a/Modules/SidePanel/Cards/MediaCard.qml +++ b/Modules/SidePanel/Cards/MediaCard.qml @@ -160,8 +160,6 @@ NBox { height: 90 * scaling radius: width * 0.5 color: trackArt.visible ? Color.mPrimary : Color.transparent - border.color: trackArt.visible ? Color.mOutline : Color.transparent - border.width: Math.max(1, Style.borderS * scaling) clip: true NImageCircled { diff --git a/Modules/SidePanel/Cards/UtilitiesCard.qml b/Modules/SidePanel/Cards/UtilitiesCard.qml index 8d3b67a..26f3b4e 100644 --- a/Modules/SidePanel/Cards/UtilitiesCard.qml +++ b/Modules/SidePanel/Cards/UtilitiesCard.qml @@ -50,6 +50,7 @@ NBox { icon: "image" tooltipText: "Open Wallpaper Selector" onClicked: { + var settingsPanel = PanelService.getPanel("settingsPanel") settingsPanel.requestedTab = SettingsPanel.Tab.WallpaperSelector settingsPanel.open(screen) } diff --git a/Modules/SidePanel/Cards/WeatherCard.qml b/Modules/SidePanel/Cards/WeatherCard.qml index 468d12c..e9c7a17 100644 --- a/Modules/SidePanel/Cards/WeatherCard.qml +++ b/Modules/SidePanel/Cards/WeatherCard.qml @@ -90,7 +90,10 @@ NBox { Layout.alignment: Qt.AlignHCenter spacing: Style.marginS * scaling NText { - text: Qt.formatDateTime(new Date(LocationService.data.weather.daily.time[index]), "ddd") + text: { + var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/")) + return Qt.formatDateTime(weatherDate, "ddd") + } color: Color.mOnSurface } NIcon { diff --git a/Modules/Toast/ToastManager.qml b/Modules/Toast/ToastManager.qml index 35fe01e..0da45c1 100644 --- a/Modules/Toast/ToastManager.qml +++ b/Modules/Toast/ToastManager.qml @@ -17,6 +17,10 @@ Variants { readonly property real scaling: ScalingService.scale(screen) screen: modelData + // Only show on screens that have notifications enabled + visible: modelData ? (Settings.data.notifications.monitors.includes(modelData.name) + || (Settings.data.notifications.monitors.length === 0)) : false + // Position based on bar location, like Notification popup does anchors { top: Settings.data.bar.position === "top" @@ -51,11 +55,15 @@ Variants { hiddenY: Settings.data.bar.position === "top" ? -toast.height - 20 : toast.height + 20 Component.onCompleted: { - // Register this toast with the service - ToastService.currentToast = toast + // Only register toasts for screens that have notifications enabled + if (modelData ? (Settings.data.notifications.monitors.includes(modelData.name) + || (Settings.data.notifications.monitors.length === 0)) : false) { + // Register this toast with the service + ToastService.allToasts.push(toast) - // Connect dismissal signal - toast.dismissed.connect(ToastService.onToastDismissed) + // Connect dismissal signal + toast.dismissed.connect(ToastService.onToastDismissed) + } } } } diff --git a/README.md b/README.md index d373d02..1901027 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Features a modern modular architecture with a status bar, notification system, c - `gpu-screen-recorder` - Screen recording functionality - `brightnessctl` - For internal/laptop monitor brightness - `ddcutil` - For desktop monitor brightness (might introduce some system instability with certain monitors) +If you want to use the ArchUpdater Widget, make sure you have any polkit agent installed. --- @@ -100,8 +101,30 @@ mkdir -p ~/.config/quickshell && curl -sL https://github.com/noctalia-dev/noctal # Start the shell qs -# Toggle launcher -qs ipc call appLauncher toggle +# Launcher +qs ipc call launcher toggle + +# SidePanel +qs ipc call sidePanel toggle + +# Clipboard History +qs ipc call launcher clipboard + +# Calculator +qs ipc call launcher calculator + +# Brightness +qs ipc call brightness increase +qs ipc call brightness decrease + +# Power Panel +qs ipc call powerPanel toggle + +# Idle Inhibitor +qs ipc call idleInhibitor toggle + +# Settings Window +qs ipc call settings toggle # Toggle lock screen qs ipc call lockScreen toggle @@ -249,6 +272,7 @@ While I actually didn't want to accept donations, more and more people are askin Thank you to everyone who supports me and this project 💜! * Gohma +* PikaOS --- diff --git a/Services/ArchUpdaterService.qml b/Services/ArchUpdaterService.qml new file mode 100644 index 0000000..611ce59 --- /dev/null +++ b/Services/ArchUpdaterService.qml @@ -0,0 +1,156 @@ +pragma Singleton + +import Quickshell +import QtQuick +import Quickshell.Io + +Singleton { + id: updateService + + // Core properties + readonly property bool busy: checkupdatesProcess.running + readonly property int updates: updatePackages.length + property var updatePackages: [] + property var selectedPackages: [] + property int selectedPackagesCount: 0 + property bool updateInProgress: false + + // Process for checking updates + Process { + id: checkupdatesProcess + command: ["checkupdates"] + onExited: function (exitCode) { + if (exitCode !== 0 && exitCode !== 2) { + console.warn("[UpdateService] checkupdates failed (code:", exitCode, ")") + updatePackages = [] + return + } + } + stdout: StdioCollector { + onStreamFinished: { + parseCheckupdatesOutput(text) + } + } + } + + // Parse checkupdates output + function parseCheckupdatesOutput(output) { + const lines = output.trim().split('\n').filter(line => line.trim()) + const packages = [] + + for (const line of lines) { + const m = line.match(/^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/) + if (m) { + packages.push({ + "name": m[1], + "oldVersion": m[2], + "newVersion": m[3], + "description": `${m[1]} ${m[2]} -> ${m[3]}` + }) + } + } + + updatePackages = packages + } + + // Check for updates + function doPoll() { + if (busy) + return + checkupdatesProcess.running = true + } + + // Update all packages + function runUpdate() { + if (updates === 0) { + doPoll() + return + } + + updateInProgress = true + Quickshell.execDetached(["pkexec", "pacman", "-Syu", "--noconfirm"]) + + // Refresh after updates with multiple attempts + refreshAfterUpdate() + } + + // Update selected packages + function runSelectiveUpdate() { + if (selectedPackages.length === 0) + return + + updateInProgress = true + const command = ["pkexec", "pacman", "-S", "--noconfirm"].concat(selectedPackages) + Quickshell.execDetached(command) + + // Clear selection and refresh + selectedPackages = [] + selectedPackagesCount = 0 + refreshAfterUpdate() + } + + // Package selection functions + function togglePackageSelection(packageName) { + const index = selectedPackages.indexOf(packageName) + if (index > -1) { + selectedPackages.splice(index, 1) + } else { + selectedPackages.push(packageName) + } + selectedPackagesCount = selectedPackages.length + } + + function selectAllPackages() { + selectedPackages = updatePackages.map(pkg => pkg.name) + selectedPackagesCount = selectedPackages.length + } + + function deselectAllPackages() { + selectedPackages = [] + selectedPackagesCount = 0 + } + + function isPackageSelected(packageName) { + return selectedPackages.indexOf(packageName) > -1 + } + + // Robust refresh after updates + function refreshAfterUpdate() { + // First refresh attempt after 3 seconds + Qt.callLater(() => { + doPoll() + }, 3000) + + // Second refresh attempt after 8 seconds + Qt.callLater(() => { + doPoll() + }, 8000) + + // Third refresh attempt after 15 seconds + Qt.callLater(() => { + doPoll() + updateInProgress = false + }, 15000) + + // Final refresh attempt after 30 seconds + Qt.callLater(() => { + doPoll() + }, 30000) + } + + // Notification helper + function notify(title, body) { + Quickshell.execDetached(["notify-send", "-a", "UpdateService", "-i", "system-software-update", title, body]) + } + + // Auto-poll every 15 minutes + Timer { + interval: 15 * 60 * 1000 // 15 minutes + repeat: true + running: true + onTriggered: doPoll() + } + + // Initial check + Component.onCompleted: doPoll() +} diff --git a/Services/BarWidgetRegistry.qml b/Services/BarWidgetRegistry.qml new file mode 100644 index 0000000..62ea73c --- /dev/null +++ b/Services/BarWidgetRegistry.qml @@ -0,0 +1,99 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Modules.Bar.Widgets + +Singleton { + id: root + + // Widget registry object mapping widget names to components + property var widgets: ({ + "ActiveWindow": activeWindowComponent, + // "ArchUpdater": archUpdaterComponent, + "Battery": batteryComponent, + "Bluetooth": bluetoothComponent, + "Brightness": brightnessComponent, + "Clock": clockComponent, + "KeyboardLayout": keyboardLayoutComponent, + "MediaMini": mediaMiniComponent, + "NotificationHistory": notificationHistoryComponent, + "PowerProfile": powerProfileComponent, + "ScreenRecorderIndicator": screenRecorderIndicatorComponent, + "SidePanelToggle": sidePanelToggleComponent, + "SystemMonitor": systemMonitorComponent, + "Tray": trayComponent, + "Volume": volumeComponent, + "WiFi": wiFiComponent, + "Workspace": workspaceComponent + }) + + // Component definitions - these are loaded once at startup + property Component activeWindowComponent: Component { + ActiveWindow {} + } + // property Component archUpdaterComponent: Component { + // ArchUpdater {} + // } + property Component batteryComponent: Component { + Battery {} + } + property Component bluetoothComponent: Component { + Bluetooth {} + } + property Component brightnessComponent: Component { + Brightness {} + } + property Component clockComponent: Component { + Clock {} + } + property Component keyboardLayoutComponent: Component { + KeyboardLayout {} + } + property Component mediaMiniComponent: Component { + MediaMini {} + } + property Component notificationHistoryComponent: Component { + NotificationHistory {} + } + property Component powerProfileComponent: Component { + PowerProfile {} + } + property Component screenRecorderIndicatorComponent: Component { + ScreenRecorderIndicator {} + } + property Component sidePanelToggleComponent: Component { + SidePanelToggle {} + } + property Component systemMonitorComponent: Component { + SystemMonitor {} + } + property Component trayComponent: Component { + Tray {} + } + property Component volumeComponent: Component { + Volume {} + } + property Component wiFiComponent: Component { + WiFi {} + } + property Component workspaceComponent: Component { + Workspace {} + } + + // ------------------------------ + // Helper function to get widget component by name + function getWidget(name) { + return widgets[name] || null + } + + // Helper function to check if widget exists + function hasWidget(name) { + return name in widgets + } + + // Get list of available widget names + function getAvailableWidgets() { + return Object.keys(widgets) + } +} diff --git a/Services/CavaService.qml b/Services/CavaService.qml index 4d41a91..6cfb735 100644 --- a/Services/CavaService.qml +++ b/Services/CavaService.qml @@ -14,7 +14,7 @@ Singleton { property var config: ({ "general": { "bars": barsCount, - "framerate": 60, + "framerate": Settings.data.audio.cavaFrameRate, "autosens": 1, "sensitivity": 100, "lower_cutoff_freq": 50, @@ -38,7 +38,7 @@ Singleton { id: process stdinEnabled: true running: (Settings.data.audio.visualizerType !== "none") - && (PanelService.sidePanel.active || Settings.data.audio.showMiniplayerCava + && (PanelService.getPanel("sidePanel").active || Settings.data.audio.showMiniplayerCava || (PanelService.lockScreen && PanelService.lockScreen.active)) command: ["cava", "-p", "/dev/stdin"] onExited: { diff --git a/Services/CompositorService.qml b/Services/CompositorService.qml index f657cb1..feb52bc 100644 --- a/Services/CompositorService.qml +++ b/Services/CompositorService.qml @@ -19,7 +19,7 @@ Singleton { property ListModel workspaces: ListModel {} property var windows: [] property int focusedWindowIndex: -1 - property string focusedWindowTitle: "(No active window)" + property string focusedWindowTitle: "n/a" property bool inOverview: false // Generic events @@ -27,6 +27,7 @@ Singleton { signal activeWindowChanged signal overviewStateChanged signal windowListChanged + signal windowTitleChanged // Compositor detection Component.onCompleted: { @@ -308,9 +309,18 @@ Singleton { // Update focused window index if this window is focused if (newWindow.isFocused) { + const oldFocusedIndex = focusedWindowIndex focusedWindowIndex = windows.findIndex(w => w.id === windowData.id) updateFocusedWindowTitle() - activeWindowChanged() + + // Only emit activeWindowChanged if the focused window actually changed + if (oldFocusedIndex !== focusedWindowIndex) { + activeWindowChanged() + } + } else if (existingIndex >= 0 && existingIndex === focusedWindowIndex) { + // If this is the currently focused window (but not newly focused), + // still update the title in case it changed, but don't emit activeWindowChanged + updateFocusedWindowTitle() } windowListChanged() @@ -449,11 +459,17 @@ Singleton { } function updateFocusedWindowTitle() { + const oldTitle = focusedWindowTitle if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.length) { focusedWindowTitle = windows[focusedWindowIndex].title || "(Unnamed window)" } else { focusedWindowTitle = "(No active window)" } + + // Emit signal if title actually changed + if (oldTitle !== focusedWindowTitle) { + windowTitleChanged() + } } // Generic workspace switching diff --git a/Services/GitHubService.qml b/Services/GitHubService.qml index 98bf6e4..cb75563 100644 --- a/Services/GitHubService.qml +++ b/Services/GitHubService.qml @@ -1,9 +1,10 @@ +pragma Singleton + import QtQuick import Quickshell import Quickshell.Io import qs.Commons import qs.Services -pragma Singleton // GitHub API logic and caching Singleton { diff --git a/Services/KeyboardLayoutService.qml b/Services/KeyboardLayoutService.qml new file mode 100644 index 0000000..c66e5c9 --- /dev/null +++ b/Services/KeyboardLayoutService.qml @@ -0,0 +1,112 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs.Commons +import qs.Services + +Singleton { + id: root + + property string currentLayout: "Unknown" + property int updateInterval: 1000 // Update every second + + // Timer to periodically update the layout + Timer { + id: updateTimer + interval: updateInterval + running: true + repeat: true + onTriggered: { + updateLayout() + } + } + + // Process to get current keyboard layout using niri msg (Wayland native) + Process { + id: niriLayoutProcess + running: false + command: ["niri", "msg", "-j", "keyboard-layouts"] + stdout: StdioCollector { + onStreamFinished: { + try { + const data = JSON.parse(text) + const layoutName = data.names[data.current_idx] + root.currentLayout = mapLayoutNameToCode(layoutName) + } catch (e) { + root.currentLayout = "Unknown" + } + } + } + } + + // Process to get current keyboard layout using hyprctl (Hyprland) + Process { + id: hyprlandLayoutProcess + running: false + command: ["hyprctl", "-j", "devices"] + stdout: StdioCollector { + onStreamFinished: { + try { + const data = JSON.parse(text) + // Find the main keyboard and get its active keymap + const mainKeyboard = data.keyboards.find(kb => kb.main === true) + if (mainKeyboard && mainKeyboard.active_keymap) { + root.currentLayout = mapLayoutNameToCode(mainKeyboard.active_keymap) + } else { + root.currentLayout = "Unknown" + } + } catch (e) { + root.currentLayout = "Unknown" + } + } + } + } + + // Layout name to ISO code mapping + property var layoutMap: { + "German": "de", + "English (US)": "us", + "English (UK)": "gb", + "French": "fr", + "Spanish": "es", + "Italian": "it", + "Portuguese (Brazil)": "br", + "Portuguese": "pt", + "Russian": "ru", + "Polish": "pl", + "Swedish": "se", + "Norwegian": "no", + "Danish": "dk", + "Finnish": "fi", + "Hungarian": "hu", + "Turkish": "tr", + "Czech": "cz", + "Slovak": "sk", + "Japanese": "jp", + "Korean": "kr", + "Chinese": "cn" + } + + // Map layout names to ISO codes + function mapLayoutNameToCode(layoutName) { + return layoutMap[layoutName] || layoutName // fallback to raw name if not found + } + + Component.onCompleted: { + Logger.log("KeyboardLayout", "Service started") + updateLayout() + } + + function updateLayout() { + if (CompositorService.isHyprland) { + hyprlandLayoutProcess.running = true + } else if (CompositorService.isNiri) { + niriLayoutProcess.running = true + } else { + currentLayout = "Unknown" + } + } +} diff --git a/Services/LocationService.qml b/Services/LocationService.qml index 0ac0ac2..2e29e88 100644 --- a/Services/LocationService.qml +++ b/Services/LocationService.qml @@ -1,9 +1,10 @@ +pragma Singleton + import QtQuick import Quickshell import Quickshell.Io import qs.Commons import qs.Services -pragma Singleton // Weather logic and caching Singleton { @@ -109,8 +110,8 @@ Singleton { // -------------------------------- function _geocodeLocation(locationName, callback, errorCallback) { - Logger.log("Location", "Geocoding from api.open-meteo.com") - var geoUrl = "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent( + Logger.log("Location", "Geocoding location name") + var geoUrl = "https://assets.noctalia.dev/geocode.php?city=" + encodeURIComponent( locationName) + "&language=en&format=json" var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { @@ -119,8 +120,8 @@ Singleton { try { var geoData = JSON.parse(xhr.responseText) // Logger.logJSON.stringify(geoData)) - if (geoData.results && geoData.results.length > 0) { - callback(geoData.results[0].latitude, geoData.results[0].longitude) + if (geoData.lat != null) { + callback(geoData.lat, geoData.lng) } else { errorCallback("Location", "could not resolve location name") } diff --git a/Services/NetworkService.qml b/Services/NetworkService.qml index 65378f0..8dde4df 100644 --- a/Services/NetworkService.qml +++ b/Services/NetworkService.qml @@ -16,6 +16,7 @@ Singleton { property string detectedInterface: "" property string lastConnectedNetwork: "" property bool isLoading: false + property bool ethernet: false Component.onCompleted: { Logger.log("Network", "Service started") @@ -43,6 +44,7 @@ Singleton { function refreshNetworks() { isLoading = true + checkEthernet.running = true existingNetwork.running = true } @@ -416,6 +418,24 @@ Singleton { } } + property Process checkEthernet: Process { + id: checkEthernet + running: false + command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"] + stdout: StdioCollector { + onStreamFinished: { + var lines = text.split("\n") + for (var i = 0; i < lines.length; ++i) { + var parts = lines[i].split(":") + if (parts[1] === "ethernet" && parts[2] === "connected") { + root.ethernet = true + break + } + } + } + } + } + property Process addConnectionProcess: Process { id: addConnectionProcess property string ifname: "" diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 3f4f44e..0354df5 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -1,10 +1,11 @@ +pragma Singleton + import QtQuick import Quickshell import Quickshell.Io import qs.Commons import qs.Services import Quickshell.Services.Notifications -pragma Singleton QtObject { id: root diff --git a/Services/PanelService.qml b/Services/PanelService.qml index e2d82f7..9a37aeb 100644 --- a/Services/PanelService.qml +++ b/Services/PanelService.qml @@ -1,20 +1,38 @@ pragma Singleton import Quickshell +import qs.Commons Singleton { id: root - // A ref. to the sidePanel, so it's accessible from other services - property var sidePanel: null - - // A ref. to the lockScreen, so it's accessible from other services + // A ref. to the lockScreen, so it's accessible from anywhere + // This is not a panel... property var lockScreen: null // Currently opened panel property var openedPanel: null - function registerOpen(panel) { + property var registeredPanels: ({}) + + // Register this panel + function registerPanel(panel) { + registeredPanels[panel.objectName] = panel + Logger.log("PanelService", "Registered:", panel.objectName) + } + + // Returns a panel + function getPanel(name) { + return registeredPanels[name] || null + } + + // Check if a panel exists + function hasPanel(name) { + return name in registeredPanels + } + + // Helper to keep only one panel open at any time + function willOpenPanel(panel) { if (openedPanel && openedPanel != panel) { openedPanel.close() } diff --git a/Services/ScreenRecorderService.qml b/Services/ScreenRecorderService.qml index 03560e7..ed35b38 100644 --- a/Services/ScreenRecorderService.qml +++ b/Services/ScreenRecorderService.qml @@ -30,12 +30,22 @@ Singleton { videoDir += "/" } outputPath = videoDir + filename - var command = `gpu-screen-recorder -w ${settings.videoSource} -f ${settings.frameRate} -ac ${settings.audioCodec} -k ${settings.videoCodec} -a ${settings.audioSource} -q ${settings.quality} -cursor ${settings.showCursor ? "yes" : "no"} -cr ${settings.colorRange} -o ${outputPath}` + var flags = `-w ${settings.videoSource} -f ${settings.frameRate} -ac ${settings.audioCodec} -k ${settings.videoCodec} -a ${settings.audioSource} -q ${settings.quality} -cursor ${settings.showCursor ? "yes" : "no"} -cr ${settings.colorRange} -o ${outputPath}` + var command = ` + _gpuscreenrecorder_flatpak_installed() { + flatpak list --app | grep -q "com.dec05eba.gpu_screen_recorder" + } + if command -v gpu-screen-recorder >/dev/null 2>&1; then + gpu-screen-recorder ${flags} + elif command -v flatpak >/dev/null 2>&1 && _gpuscreenrecorder_flatpak_installed; then + flatpak run --command=gpu-screen-recorder --file-forwarding com.dec05eba.gpu_screen_recorder ${flags} + else + notify-send "gpu-screen-recorder not installed!" -u critical + fi` //Logger.log("ScreenRecorder", command) Quickshell.execDetached(["sh", "-c", command]) Logger.log("ScreenRecorder", "Started recording") - //Logger.log("ScreenRecorder", command) } // Stop recording using Quickshell.execDetached diff --git a/Services/ToastService.qml b/Services/ToastService.qml index b08a366..5db7139 100644 --- a/Services/ToastService.qml +++ b/Services/ToastService.qml @@ -1,18 +1,77 @@ pragma Singleton import QtQuick +import Quickshell import Quickshell.Io import qs.Commons -QtObject { +Singleton { id: root // Queue of pending toast messages property var messageQueue: [] property bool isShowingToast: false - // Reference to the current toast instance (set by ToastManager) - property var currentToast: null + // Reference to all toast instances (set by ToastManager) + property var allToasts: [] + + // Properties for command checking + property var commandCheckCallback: null + property string commandCheckSuccessMessage: "" + property string commandCheckFailMessage: "" + + // Properties for command running + property var commandRunCallback: null + property string commandRunSuccessMessage: "" + property string commandRunFailMessage: "" + + // Properties for delayed toast + property string delayedToastMessage: "" + property string delayedToastType: "notice" + + // Process for command checking + Process { + id: commandCheckProcess + command: ["which", "test"] + onExited: function (exitCode) { + if (exitCode === 0) { + showNotice(commandCheckSuccessMessage) + if (commandCheckCallback) + commandCheckCallback() + } else { + showWarning(commandCheckFailMessage) + } + } + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + // Process for command running + Process { + id: commandRunProcess + command: ["echo", "test"] + onExited: function (exitCode) { + if (exitCode === 0) { + showNotice(commandRunSuccessMessage) + if (commandRunCallback) + commandRunCallback() + } else { + showWarning(commandRunFailMessage) + } + } + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + // Timer for delayed toast + Timer { + id: delayedToastTimer + interval: 1000 + repeat: false + onTriggered: { + showToast(delayedToastMessage, delayedToastType) + } + } // Methods to show different types of messages function showNotice(label, description = "", persistent = false, duration = 3000) { @@ -25,37 +84,14 @@ QtObject { // Utility function to check if a command exists and show appropriate toast function checkCommandAndToast(command, successMessage, failMessage, onSuccess = null) { - var checkProcess = Qt.createQmlObject(` - import QtQuick - import Quickshell.Io - Process { - id: checkProc - command: ["which", "${command}"] - running: true + // Store callback for use in the process + commandCheckCallback = onSuccess + commandCheckSuccessMessage = successMessage + commandCheckFailMessage = failMessage - property var onSuccessCallback: null - property bool hasFinished: false - - onExited: { - if (!hasFinished) { - hasFinished = true - if (exitCode === 0) { - ToastService.showNotice("${successMessage}") - if (onSuccessCallback) onSuccessCallback() - } else { - ToastService.showWarning("${failMessage}") - } - checkProc.destroy() - } - } - - // Fallback collectors to prevent issues - stdout: StdioCollector {} - stderr: StdioCollector {} - } - `, root) - - checkProcess.onSuccessCallback = onSuccess + // Start the command check process + commandCheckProcess.command = ["which", command] + commandCheckProcess.running = true } // Simple function to show a random toast (useful for testing or fun messages) @@ -95,37 +131,14 @@ QtObject { // Generic command runner with toast feedback function runCommandWithToast(command, args, successMessage, failMessage, onSuccess = null) { - var fullCommand = [command].concat(args || []) - var runProcess = Qt.createQmlObject(` - import QtQuick - import Quickshell.Io - Process { - id: runProc - command: ${JSON.stringify(fullCommand)} - running: true + // Store callback for use in the process + commandRunCallback = onSuccess + commandRunSuccessMessage = successMessage + commandRunFailMessage = failMessage - property var onSuccessCallback: null - property bool hasFinished: false - - onExited: { - if (!hasFinished) { - hasFinished = true - if (exitCode === 0) { - ToastService.showNotice("${successMessage}") - if (onSuccessCallback) onSuccessCallback() - } else { - ToastService.showWarning("${failMessage}") - } - runProc.destroy() - } - } - - stdout: StdioCollector {} - stderr: StdioCollector {} - } - `, root) - - runProcess.onSuccessCallback = onSuccess + // Start the command run process + commandRunProcess.command = [command].concat(args || []) + commandRunProcess.running = true } // Check if a file/directory exists @@ -135,18 +148,10 @@ QtObject { // Show toast after a delay (useful for delayed feedback) function delayedToast(message, type = "notice", delayMs = 1000) { - var timer = Qt.createQmlObject(` - import QtQuick - Timer { - interval: ${delayMs} - repeat: false - running: true - onTriggered: { - ToastService.showToast("${message}", "${type}") - destroy() - } - } - `, root) + delayedToastMessage = message + delayedToastType = type + delayedToastTimer.interval = delayMs + delayedToastTimer.restart() } // Generic method to show a toast @@ -171,7 +176,7 @@ QtObject { // Process the message queue function processQueue() { - if (messageQueue.length === 0 || !currentToast) { + if (messageQueue.length === 0 || allToasts.length === 0) { isShowingToast = false return } @@ -184,24 +189,37 @@ QtObject { var toastData = messageQueue.shift() isShowingToast = true - // Configure and show toast - currentToast.label = toastData.label - currentToast.description = toastData.description - currentToast.type = toastData.type - currentToast.persistent = toastData.persistent - currentToast.duration = toastData.duration - currentToast.show() + // Configure and show toast on all screens + for (var i = 0; i < allToasts.length; i++) { + var toast = allToasts[i] + toast.label = toastData.label + toast.description = toastData.description + toast.type = toastData.type + toast.persistent = toastData.persistent + toast.duration = toastData.duration + toast.show() + } } // Called when a toast is dismissed function onToastDismissed() { + // Check if all toasts are dismissed + var allDismissed = true + for (var i = 0; i < allToasts.length; i++) { + if (allToasts[i].visible) { + allDismissed = false + break + } + } - isShowingToast = false + if (allDismissed) { + isShowingToast = false - // Small delay before showing next toast - Qt.callLater(function () { - processQueue() - }) + // Small delay before showing next toast + Qt.callLater(function () { + processQueue() + }) + } } // Clear all pending messages @@ -212,8 +230,10 @@ QtObject { // Hide current toast function hideCurrentToast() { - if (currentToast && isShowingToast) { - currentToast.hide() + if (isShowingToast) { + for (var i = 0; i < allToasts.length; i++) { + allToasts[i].hide() + } } } diff --git a/Widgets/NCard.qml b/Widgets/NCard.qml deleted file mode 100644 index a8f7cc0..0000000 --- a/Widgets/NCard.qml +++ /dev/null @@ -1,16 +0,0 @@ -import QtQuick -import qs.Commons -import qs.Services - -// Generic card container -Rectangle { - id: root - - implicitWidth: childrenRect.width - implicitHeight: childrenRect.height - - color: Color.mSurface - radius: Style.radiusM * scaling - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS * scaling) -} diff --git a/Widgets/NComboBox.qml b/Widgets/NComboBox.qml index 1cb33bb..d2fdb8d 100644 --- a/Widgets/NComboBox.qml +++ b/Widgets/NComboBox.qml @@ -8,14 +8,14 @@ import qs.Widgets ColumnLayout { id: root - readonly property real preferredHeight: Style.baseWidgetSize * 1.25 * scaling + readonly property real preferredHeight: Style.baseWidgetSize * 1.35 * scaling property string label: "" property string description: "" property ListModel model: { } - property string currentKey: '' + property string currentKey: "" property string placeholder: "" signal selected(string key) @@ -39,7 +39,8 @@ ColumnLayout { ComboBox { id: combo - Layout.fillWidth: true + + Layout.preferredWidth: 320 * scaling Layout.preferredHeight: height model: model currentIndex: findIndexByKey(currentKey) @@ -128,5 +129,13 @@ ColumnLayout { radius: Style.radiusM * scaling } } + + // Update the currentIndex if the currentKey is changed externalyu + Connections { + target: root + function onCurrentKeyChanged() { + combo.currentIndex = root.findIndexByKey(currentKey) + } + } } } diff --git a/Widgets/NPanel.qml b/Widgets/NPanel.qml index 97a8705..dc8e162 100644 --- a/Widgets/NPanel.qml +++ b/Widgets/NPanel.qml @@ -31,6 +31,12 @@ Loader { signal opened signal closed + Component.onCompleted: { + // console.log("Oh Yeah") + // console.log(objectName) + PanelService.registerPanel(root) + } + // ----------------------------------------- function toggle(aScreen) { if (!active || isClosing) { @@ -53,7 +59,7 @@ Loader { opacityValue = 1.0 } - PanelService.registerOpen(root) + PanelService.willOpenPanel(root) active = true root.opened() diff --git a/Widgets/NPill.qml b/Widgets/NPill.qml index b3739eb..8f6c3d4 100644 --- a/Widgets/NPill.qml +++ b/Widgets/NPill.qml @@ -16,10 +16,11 @@ Item { property color collapsedIconColor: Color.mOnSurface property real sizeMultiplier: 0.8 property bool autoHide: false - // When true, keep the pill expanded regardless of hover state - property bool forceShown: false + property bool forceOpen: false + property bool disableOpen: false + // Effective shown state (true if hovered/animated open or forced) - readonly property bool effectiveShown: forceShown || showPill + readonly property bool effectiveShown: forceOpen || showPill signal shown signal hidden @@ -85,7 +86,7 @@ Item { height: iconSize radius: width * 0.5 // When forced shown, match pill background; otherwise use accent when hovered - color: forceShown ? pillColor : (showPill ? iconCircleColor : Color.mSurfaceVariant) + color: forceOpen ? pillColor : (showPill ? iconCircleColor : Color.mSurfaceVariant) anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right @@ -100,7 +101,7 @@ Item { text: root.icon font.pointSize: Style.fontSizeM * scaling // When forced shown, use pill text color; otherwise accent color when hovered - color: forceShown ? textColor : (showPill ? iconTextColor : Color.mOnSurface) + color: forceOpen ? textColor : (showPill ? iconTextColor : Color.mOnSurface) anchors.centerIn: parent } } @@ -194,18 +195,21 @@ Item { anchors.fill: parent hoverEnabled: true onEntered: { - if (!forceShown) { + root.entered() + tooltip.show() + if (disableOpen) { + return + } + if (!forceOpen) { showDelayed() } - tooltip.show() - root.entered() } onExited: { - if (!forceShown) { + root.exited() + if (!forceOpen) { hide() } tooltip.hide() - root.exited() } onClicked: { root.clicked() @@ -226,7 +230,7 @@ Item { } function hide() { - if (forceShown) { + if (forceOpen) { return } if (showPill) { @@ -245,8 +249,8 @@ Item { } } - onForceShownChanged: { - if (forceShown) { + onForceOpenChanged: { + if (forceOpen) { // Immediately lock open without animations showAnim.stop() hideAnim.stop() diff --git a/Widgets/NSectionEditor.qml b/Widgets/NSectionEditor.qml new file mode 100644 index 0000000..9103dcd --- /dev/null +++ b/Widgets/NSectionEditor.qml @@ -0,0 +1,319 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +NBox { + id: root + + property string sectionName: "" + property var widgetModel: [] + property var availableWidgets: [] + property var scrollView: null + + signal addWidget(string widgetName, string section) + signal removeWidget(string section, int index) + signal reorderWidget(string section, int fromIndex, int toIndex) + + color: Color.mSurface + Layout.fillWidth: true + Layout.minimumHeight: { + var widgetCount = widgetModel.length + if (widgetCount === 0) + return 140 * scaling + + var availableWidth = scrollView ? scrollView.availableWidth - (Style.marginM * scaling * 2) : 400 * scaling + var avgWidgetWidth = 150 * scaling + var widgetsPerRow = Math.max(1, Math.floor(availableWidth / avgWidgetWidth)) + var rows = Math.ceil(widgetCount / widgetsPerRow) + + return (50 + 20 + (rows * 48) + ((rows - 1) * Style.marginS) + 20) * scaling + } + + // Generate widget color from name checksum + function getWidgetColor(name) { + const totalSum = name.split('').reduce((acc, character) => { + return acc + character.charCodeAt(0) + }, 0) + switch (totalSum % 5) { + case 0: + return Color.mPrimary + case 1: + return Color.mSecondary + case 2: + return Color.mTertiary + case 3: + return Color.mError + case 4: + return Color.mOnSurface + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM * scaling + spacing: Style.marginM * scaling + + RowLayout { + Layout.fillWidth: true + + NText { + text: sectionName + " Section" + font.pointSize: Style.fontSizeL * scaling + font.weight: Style.fontWeightBold + color: Color.mSecondary + Layout.alignment: Qt.AlignVCenter + } + + Item { + Layout.fillWidth: true + } + NComboBox { + id: comboBox + model: availableWidgets + label: "" + description: "" + placeholder: "Add widget to the " + sectionName.toLowerCase() + " section..." + onSelected: key => { + comboBox.currentKey = key + } + Layout.alignment: Qt.AlignVCenter + } + + NIconButton { + icon: "add" + + colorBg: Color.mPrimary + colorFg: Color.mOnPrimary + colorBgHover: Color.mSecondary + colorFgHover: Color.mOnSecondary + enabled: comboBox.selectedKey !== "" + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: Style.marginS * scaling + onClicked: { + if (comboBox.currentKey !== "") { + addWidget(comboBox.currentKey, sectionName.toLowerCase()) + comboBox.currentKey = "battery" + } + } + } + } + + // Drag and Drop Widget Area + Flow { + id: widgetFlow + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 65 * scaling + spacing: Style.marginS * scaling + flow: Flow.LeftToRight + + Repeater { + model: widgetModel + delegate: Rectangle { + id: widgetItem + required property int index + required property string modelData + + width: widgetContent.implicitWidth + 16 * scaling + height: 40 * scaling + radius: Style.radiusL * scaling + color: root.getWidgetColor(modelData) + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + + // Drag properties + Drag.keys: ["widget"] + Drag.active: mouseArea.drag.active + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + + // Store the widget index for drag operations + property int widgetIndex: index + + // Visual feedback during drag + states: State { + when: mouseArea.drag.active + PropertyChanges { + target: widgetItem + scale: 1.1 + opacity: 0.9 + z: 1000 + } + } + + RowLayout { + id: widgetContent + anchors.centerIn: parent + spacing: Style.marginXS * scaling + + NText { + text: modelData + font.pointSize: Style.fontSizeS * scaling + color: Color.mOnPrimary + horizontalAlignment: Text.AlignHCenter + } + + NIconButton { + icon: "close" + size: 20 * scaling + colorBorder: Color.applyOpacity(Color.mOutline, "40") + colorBg: Color.mOnSurface + colorFg: Color.mOnPrimary + colorBgHover: Color.applyOpacity(Color.mOnPrimary, "40") + colorFgHover: Color.mOnPrimary + onClicked: { + removeWidget(sectionName.toLowerCase(), index) + } + } + } + + // Mouse area for drag and drop + MouseArea { + id: mouseArea + anchors.fill: parent + drag.target: parent + + onPressed: mouse => { + // Check if the click is on the close button area + const closeButtonX = widgetContent.x + widgetContent.width - 20 * scaling + const closeButtonY = widgetContent.y + const closeButtonWidth = 20 * scaling + const closeButtonHeight = 20 * scaling + + if (mouseX >= closeButtonX && mouseX <= closeButtonX + closeButtonWidth + && mouseY >= closeButtonY && mouseY <= closeButtonY + closeButtonHeight) { + // Click is on the close button, don't start drag + mouse.accepted = false + return + } + + Logger.log("NSectionEditor", `Started dragging widget: ${modelData} at index ${index}`) + // Bring to front when starting drag + widgetItem.z = 1000 + } + + onReleased: { + Logger.log("NSectionEditor", `Released widget: ${modelData} at index ${index}`) + // Reset z-index when drag ends + widgetItem.z = 0 + + // Get the global mouse position + const globalDropX = mouseArea.mouseX + widgetItem.x + widgetFlow.x + const globalDropY = mouseArea.mouseY + widgetItem.y + widgetFlow.y + + // Find which widget the drop position is closest to + let targetIndex = -1 + let minDistance = Infinity + + for (var i = 0; i < widgetModel.length; i++) { + if (i !== index) { + // Get the position of other widgets + const otherWidget = widgetFlow.children[i] + if (otherWidget && otherWidget.widgetIndex !== undefined) { + // Calculate the center of the other widget + const otherCenterX = otherWidget.x + otherWidget.width / 2 + widgetFlow.x + const otherCenterY = otherWidget.y + otherWidget.height / 2 + widgetFlow.y + + // Calculate distance to the center of this widget + const distance = Math.sqrt(Math.pow(globalDropX - otherCenterX, + 2) + Math.pow(globalDropY - otherCenterY, 2)) + + if (distance < minDistance) { + minDistance = distance + targetIndex = otherWidget.widgetIndex + } + } + } + } + + // Only reorder if we found a valid target and it's different from current position + if (targetIndex !== -1 && targetIndex !== index) { + const fromIndex = index + const toIndex = targetIndex + Logger.log( + "NSectionEditor", + `Dropped widget from index ${fromIndex} to position ${toIndex} (distance: ${minDistance.toFixed( + 2)})`) + reorderWidget(sectionName.toLowerCase(), fromIndex, toIndex) + } else { + Logger.log("NSectionEditor", `No valid drop target found for widget at index ${index}`) + } + } + } + } + } + } + + // Drop zone at the beginning (positioned absolutely) + DropArea { + id: startDropZone + width: 40 * scaling + height: 40 * scaling + x: widgetFlow.x + y: widgetFlow.y + (widgetFlow.height - height) / 2 + keys: ["widget"] + z: 1001 // Above the Flow + + Rectangle { + anchors.fill: parent + color: startDropZone.containsDrag ? Color.applyOpacity(Color.mPrimary, "20") : Color.transparent + border.color: startDropZone.containsDrag ? Color.mPrimary : Color.transparent + border.width: startDropZone.containsDrag ? 2 : 0 + radius: Style.radiusS * scaling + } + + onEntered: function (drag) { + Logger.log("NSectionEditor", "Entered start drop zone") + } + + onDropped: function (drop) { + Logger.log("NSectionEditor", "Dropped on start zone") + if (drop.source && drop.source.widgetIndex !== undefined) { + const fromIndex = drop.source.widgetIndex + const toIndex = 0 // Insert at the beginning + if (fromIndex !== toIndex) { + Logger.log("NSectionEditor", `Dropped widget from index ${fromIndex} to beginning`) + reorderWidget(sectionName.toLowerCase(), fromIndex, toIndex) + } + } + } + } + + // Drop zone at the end (positioned absolutely) + DropArea { + id: endDropZone + width: 40 * scaling + height: 40 * scaling + x: widgetFlow.x + widgetFlow.width - width + y: widgetFlow.y + (widgetFlow.height - height) / 2 + keys: ["widget"] + z: 1001 // Above the Flow + + Rectangle { + anchors.fill: parent + color: endDropZone.containsDrag ? Color.applyOpacity(Color.mPrimary, "20") : Color.transparent + border.color: endDropZone.containsDrag ? Color.mPrimary : Color.transparent + border.width: endDropZone.containsDrag ? 2 : 0 + radius: Style.radiusS * scaling + } + + onEntered: function (drag) { + Logger.log("NSectionEditor", "Entered end drop zone") + } + + onDropped: function (drop) { + Logger.log("NSectionEditor", "Dropped on end zone") + if (drop.source && drop.source.widgetIndex !== undefined) { + const fromIndex = drop.source.widgetIndex + const toIndex = widgetModel.length // Insert at the end + if (fromIndex !== toIndex) { + Logger.log("NSectionEditor", `Dropped widget from index ${fromIndex} to end`) + reorderWidget(sectionName.toLowerCase(), fromIndex, toIndex) + } + } + } + } + } +} diff --git a/Widgets/NWidgetCard.qml b/Widgets/NWidgetCard.qml deleted file mode 100644 index 2808465..0000000 --- a/Widgets/NWidgetCard.qml +++ /dev/null @@ -1,158 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import qs.Commons -import qs.Widgets - -NCard { - id: root - - property string sectionName: "" - property var widgetModel: [] - property var availableWidgets: [] - property var scrollView: null - - signal addWidget(string widgetName, string section) - signal removeWidget(string section, int index) - signal reorderWidget(string section, int fromIndex, int toIndex) - - Layout.fillWidth: true - Layout.minimumHeight: { - var widgetCount = widgetModel.length - if (widgetCount === 0) - return 140 * scaling - - var availableWidth = scrollView ? scrollView.availableWidth - (Style.marginM * scaling * 2) : 400 * scaling - var avgWidgetWidth = 150 * scaling - var widgetsPerRow = Math.max(1, Math.floor(availableWidth / avgWidgetWidth)) - var rows = Math.ceil(widgetCount / widgetsPerRow) - - return (50 + 20 + (rows * 48) + ((rows - 1) * Style.marginS) + 20) * scaling - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginM * scaling - spacing: Style.marginM * scaling - - RowLayout { - Layout.fillWidth: true - - NText { - text: sectionName + " Section" - font.pointSize: Style.fontSizeL * scaling - font.weight: Style.fontWeightBold - color: Color.mOnSurface - Layout.alignment: Qt.AlignVCenter - } - - Item { - Layout.fillWidth: true - } - - NComboBox { - id: comboBox - width: 120 * scaling - model: availableWidgets - label: "" - description: "" - placeholder: "Add widget to " + sectionName.toLowerCase() + " section" - onSelected: key => { - comboBox.selectedKey = key - } - } - - NIconButton { - icon: "add" - size: 24 * scaling - colorBg: Color.mPrimary - colorFg: Color.mOnPrimary - colorBgHover: Color.mPrimaryContainer - colorFgHover: Color.mOnPrimaryContainer - enabled: comboBox.selectedKey !== "" - Layout.alignment: Qt.AlignVCenter - onClicked: { - if (comboBox.selectedKey !== "") { - addWidget(comboBox.selectedKey, sectionName.toLowerCase()) - comboBox.reset() - } - } - } - } - - Flow { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: 65 * scaling - spacing: Style.marginS * scaling - flow: Flow.LeftToRight - - Repeater { - model: widgetModel - delegate: Rectangle { - width: widgetContent.implicitWidth + 16 * scaling - height: 48 * scaling - radius: Style.radiusS * scaling - color: Color.mPrimary - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS * scaling) - - RowLayout { - id: widgetContent - anchors.centerIn: parent - spacing: Style.marginXS * scaling - - NIconButton { - icon: "chevron_left" - size: 20 * scaling - colorBg: Color.applyOpacity(Color.mOnPrimary, "20") - colorFg: Color.mOnPrimary - colorBgHover: Color.applyOpacity(Color.mOnPrimary, "40") - colorFgHover: Color.mOnPrimary - enabled: index > 0 - onClicked: { - if (index > 0) { - reorderWidget(sectionName.toLowerCase(), index, index - 1) - } - } - } - - NText { - text: modelData - font.pointSize: Style.fontSizeS * scaling - color: Color.mOnPrimary - horizontalAlignment: Text.AlignHCenter - } - - NIconButton { - icon: "chevron_right" - size: 20 * scaling - colorBg: Color.applyOpacity(Color.mOnPrimary, "20") - colorFg: Color.mOnPrimary - colorBgHover: Color.applyOpacity(Color.mOnPrimary, "40") - colorFgHover: Color.mOnPrimary - enabled: index < widgetModel.length - 1 - onClicked: { - if (index < widgetModel.length - 1) { - reorderWidget(sectionName.toLowerCase(), index, index + 1) - } - } - } - - NIconButton { - icon: "close" - size: 20 * scaling - colorBg: Color.applyOpacity(Color.mOnPrimary, "20") - colorFg: Color.mOnPrimary - colorBgHover: Color.applyOpacity(Color.mOnPrimary, "40") - colorFgHover: Color.mOnPrimary - onClicked: { - removeWidget(sectionName.toLowerCase(), index) - } - } - } - } - } - } - } -} diff --git a/Widgets/NWidgetLoader.qml b/Widgets/NWidgetLoader.qml new file mode 100644 index 0000000..600b5e7 --- /dev/null +++ b/Widgets/NWidgetLoader.qml @@ -0,0 +1,46 @@ +import QtQuick +import Quickshell +import qs.Services + +Item { + id: root + + property string widgetName: "" + property var widgetProps: ({}) + property bool enabled: true + + // Don't reserve space unless the loaded widget is really visible + implicitWidth: loader.item ? loader.item.visible ? loader.item.implicitWidth : 0 : 0 + implicitHeight: loader.item ? loader.item.visible ? loader.item.implicitHeight : 0 : 0 + + Loader { + id: loader + + anchors.fill: parent + active: enabled && widgetName !== "" + sourceComponent: { + if (!active) { + return null + } + return BarWidgetRegistry.getWidget(widgetName) + } + + onLoaded: { + if (item && widgetProps) { + // Apply properties to loaded widget + for (var prop in widgetProps) { + if (item.hasOwnProperty(prop)) { + item[prop] = widgetProps[prop] + } + } + } + } + } + + // Error handling + onWidgetNameChanged: { + if (widgetName && !BarWidgetRegistry.hasWidget(widgetName)) { + Logger.warn("WidgetLoader", "Widget not found in registry:", widgetName) + } + } +} diff --git a/shell.qml b/shell.qml index 8ce8361..370b788 100644 --- a/shell.qml +++ b/shell.qml @@ -27,6 +27,7 @@ import qs.Modules.PowerPanel import qs.Modules.SidePanel import qs.Modules.Toast import qs.Modules.WiFiPanel +import qs.Modules.ArchUpdaterPanel import qs.Services import qs.Widgets @@ -39,55 +40,67 @@ ShellRoot { Bar {} Dock {} - Launcher { - id: launcherPanel - } - - SidePanel { - id: sidePanel - } - - Calendar { - id: calendarPanel - } - - SettingsPanel { - id: settingsPanel - } - Notification { id: notification } - NotificationHistoryPanel { - id: notificationHistoryPanel - } - LockScreen { id: lockScreen } - PowerPanel { - id: powerPanel - } - - WiFiPanel { - id: wifiPanel - } - - BluetoothPanel { - id: bluetoothPanel - } - ToastManager {} IPCManager {} - Component.onCompleted: { - // Save a ref. to our sidePanel so we can access it from services - PanelService.sidePanel = sidePanel + // ------------------------------ + // All the panels + Launcher { + id: launcherPanel + objectName: "launcherPanel" + } - // Save a ref. to our lockScreen so we can access it from services + SidePanel { + id: sidePanel + objectName: "sidePanel" + } + + Calendar { + id: calendarPanel + objectName: "calendarPanel" + } + + SettingsPanel { + id: settingsPanel + objectName: "settingsPanel" + } + + NotificationHistoryPanel { + id: notificationHistoryPanel + objectName: "notificationHistoryPanel" + } + + PowerPanel { + id: powerPanel + objectName: "powerPanel" + } + + WiFiPanel { + id: wifiPanel + objectName: "wifiPanel" + } + + BluetoothPanel { + id: bluetoothPanel + objectName: "bluetoothPanel" + } + + ArchUpdaterPanel { + id: archUpdaterPanel + objectName: "archUpdaterPanel" + } + + Component.onCompleted: { + // Save a ref. to our lockScreen so we can access it easily PanelService.lockScreen = lockScreen // Ensure our singleton is created as soon as possible so we start fetching weather asap