diff --git a/Commons/Settings.qml b/Commons/Settings.qml index df47f95..17187be 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -276,6 +276,9 @@ Singleton { property string startTime: "20:00" property string stopTime: "07:00" property bool autoSchedule: false + // wlsunset temperatures (Kelvin) + property int lowTemp: 3500 + property int highTemp: 6500 } } } diff --git a/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml b/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml index 3ce2656..a4759ca 100644 --- a/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml +++ b/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml @@ -34,7 +34,7 @@ NPanel { spacing: Style.marginM * scaling NIcon { - text: "system_update" + text: "system_update_alt" font.pointSize: Style.fontSizeXXL * scaling color: Color.mPrimary } @@ -77,11 +77,9 @@ NPanel { } // Unified list - Rectangle { + NBox { Layout.fillWidth: true Layout.fillHeight: true - color: Color.mSurfaceVariant - radius: Style.radiusM * scaling // Combine repo and AUR lists in order: repos first, then AUR property var items: (ArchUpdaterService.repoPackages || []).concat(ArchUpdaterService.aurPackages || []) @@ -89,33 +87,35 @@ NPanel { ListView { id: unifiedList anchors.fill: parent - anchors.margins: Style.marginS * scaling + anchors.margins: Style.marginM * scaling + cacheBuffer: Math.round(300 * scaling) clip: true + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } model: parent.items - spacing: Style.marginXS * scaling - cacheBuffer: 300 * scaling - delegate: Rectangle { width: unifiedList.width - height: 56 * scaling + height: 44 * scaling color: Color.transparent radius: Style.radiusS * scaling RowLayout { anchors.fill: parent - anchors.margins: Style.marginS * scaling spacing: Style.marginS * scaling - // Checkbox for selection (pure bindings; no imperative state) - NIconButton { + // Checkbox for selection + NCheckbox { id: checkbox - icon: ArchUpdaterService.isPackageSelected(modelData.name) ? "check_box" : "check_box_outline_blank" - onClicked: ArchUpdaterService.togglePackageSelection(modelData.name) - colorBg: Color.transparent - colorFg: ArchUpdaterService.isPackageSelected( - modelData.name) ? ((modelData.source === "aur") ? Color.mSecondary : Color.mPrimary) : Color.mOnSurfaceVariant - Layout.preferredWidth: 30 * scaling - Layout.preferredHeight: 30 * scaling + label: "" + description: "" + checked: ArchUpdaterService.isPackageSelected(modelData.name) + baseSize: Math.max(Style.baseWidgetSize * 0.7, 14) + onToggled: function (checked) { + ArchUpdaterService.togglePackageSelection(modelData.name) + // Force refresh of the checked property + checkbox.checked = ArchUpdaterService.isPackageSelected(modelData.name) + } } // Package info @@ -123,51 +123,43 @@ NPanel { Layout.fillWidth: true spacing: Style.marginXXS * scaling - RowLayout { + NText { + text: modelData.name + font.pointSize: Style.fontSizeS * scaling + font.weight: Style.fontWeightBold + color: Color.mOnSurface Layout.fillWidth: true - spacing: Style.marginXS * scaling - - NText { - text: modelData.name - font.pointSize: Style.fontSizeM * scaling - font.weight: Style.fontWeightMedium - color: Color.mOnSurface - Layout.fillWidth: true - } - - // Source badge (custom rectangle) - Rectangle { - visible: !!modelData.source - radius: 9999 - color: modelData.source === "aur" ? Color.mSecondary : Color.mPrimary - Layout.alignment: Qt.AlignVCenter - implicitHeight: Math.max(Style.fontSizeXS * 1.7 * scaling, 16 * scaling) - // Width based on label content + horizontal padding - implicitWidth: badgeText.implicitWidth + Math.max(12 * scaling, Style.marginS * scaling) - - NText { - id: badgeText - anchors.centerIn: parent - text: modelData.source === "aur" ? "AUR" : "Repo" - font.pointSize: Style.fontSizeXS * scaling - font.weight: Style.fontWeightBold - color: modelData.source === "aur" ? Color.mOnSecondary : Color.mOnPrimary - } - } + Layout.alignment: Qt.AlignVCenter } NText { text: modelData.oldVersion + " → " + modelData.newVersion - font.pointSize: Style.fontSizeS * scaling + font.pointSize: Style.fontSizeXXS * scaling color: Color.mOnSurfaceVariant Layout.fillWidth: true } } - } - } - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded + // Source tag (AUR vs PAC) + Rectangle { + visible: !!modelData.source + radius: width * 0.5 + color: modelData.source === "aur" ? Color.mTertiary : Color.mSecondary + Layout.alignment: Qt.AlignVCenter + implicitHeight: Style.fontSizeS * 1.8 * scaling + // Width based on label content + horizontal padding + implicitWidth: badgeText.implicitWidth + Math.max(12 * scaling, Style.marginS * scaling) + + NText { + id: badgeText + anchors.centerIn: parent + text: modelData.source === "aur" ? "AUR" : "PAC" + font.pointSize: Style.fontSizeXXS * scaling + font.weight: Style.fontWeightBold + color: modelData.source === "aur" ? Color.mOnTertiary : Color.mOnSecondary + } + } + } } } } @@ -190,7 +182,7 @@ NPanel { } NIconButton { - icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "system_update" + icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "system_update_alt" tooltipText: ArchUpdaterService.updateInProgress ? "Update in progress..." : "Update all packages" enabled: !ArchUpdaterService.updateInProgress onClicked: { @@ -203,7 +195,7 @@ NPanel { } NIconButton { - icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "settings" + icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "check_box" tooltipText: ArchUpdaterService.updateInProgress ? "Update in progress..." : "Update selected packages" enabled: !ArchUpdaterService.updateInProgress && ArchUpdaterService.selectedPackagesCount > 0 onClicked: { @@ -213,9 +205,9 @@ NPanel { } } colorBg: ArchUpdaterService.updateInProgress ? Color.mSurfaceVariant : (ArchUpdaterService.selectedPackagesCount - > 0 ? Color.mSecondary : Color.mSurfaceVariant) + > 0 ? Color.mPrimary : Color.mSurfaceVariant) colorFg: ArchUpdaterService.updateInProgress ? Color.mOnSurfaceVariant : (ArchUpdaterService.selectedPackagesCount - > 0 ? Color.mOnSecondary : Color.mOnSurfaceVariant) + > 0 ? Color.mOnPrimary : Color.mOnSurfaceVariant) Layout.fillWidth: true } } diff --git a/Modules/Bar/Widgets/ArchUpdater.qml b/Modules/Bar/Widgets/ArchUpdater.qml index 9047c1b..106b167 100644 --- a/Modules/Bar/Widgets/ArchUpdater.qml +++ b/Modules/Bar/Widgets/ArchUpdater.qml @@ -14,72 +14,51 @@ NIconButton { sizeRatio: 0.8 colorBg: Color.mSurfaceVariant - // Highlight color based on update source - colorFg: { - if (ArchUpdaterService.totalUpdates === 0) - return Color.mOnSurface - if (ArchUpdaterService.updates > 0 && ArchUpdaterService.aurUpdates > 0) - return Color.mPrimary - if (ArchUpdaterService.updates > 0) - return Color.mPrimary - return Color.mSecondary - } colorBorder: Color.transparent colorBorderHover: Color.transparent + colorFg: (ArchUpdaterService.totalUpdates === 0) ? Color.mOnSurface : Color.mPrimary // Icon states icon: { - if (ArchUpdaterService.busy || ArchUpdaterService.aurBusy) + if (ArchUpdaterService.busy || ArchUpdaterService.aurBusy) { return "sync" + } if (ArchUpdaterService.totalUpdates > 0) { - const count = ArchUpdaterService.totalUpdates - if (count > 50) - return "system_update_alt" - if (count > 10) - return "system_update" - return "system_update" + return "system_update_alt" } return "task_alt" } // Tooltip with repo vs AUR breakdown and sample lists tooltipText: { - if (ArchUpdaterService.busy || ArchUpdaterService.aurBusy) + if (ArchUpdaterService.busy || ArchUpdaterService.aurBusy) { return "Checking for updates…" - - const repoCount = ArchUpdaterService.updates - const aurCount = ArchUpdaterService.aurUpdates - const total = ArchUpdaterService.totalUpdates - - if (total === 0) - return "System is up to date ✓" - - let header = total === 1 ? "One package can be upgraded:" : (total + " packages can be upgraded:") - - function sampleList(arr, n, colorLabel) { - const limit = Math.min(arr.length, n) - let s = "" - for (var i = 0; i < limit; ++i) { - const p = arr[i] - s += (i ? "\n" : "") + (p.name + ": " + p.oldVersion + " → " + p.newVersion) - } - if (arr.length > limit) - s += "\n… and " + (arr.length - limit) + " more" - return (colorLabel ? (colorLabel + "\n") : "") + (s || "None") } - const repoHeader = repoCount > 0 ? ("Repo (" + repoCount + "):") : "Repo: 0" - const aurHeader = aurCount > 0 ? ("AUR (" + aurCount + "):") : "AUR: 0" + const total = ArchUpdaterService.totalUpdates + if (total === 0) { + return "System is up to date ✓" + } + let header = (total === 1) ? "1 package can be updated" : (total + " packages can be updated") - const repoBlock = repoCount > 0 ? (repoHeader + "\n\n" + sampleList(ArchUpdaterService.repoPackages, - 5)) : repoHeader - const aurBlock = aurCount > 0 ? (aurHeader + "\n\n" + sampleList(ArchUpdaterService.aurPackages, 5)) : aurHeader + const pacCount = ArchUpdaterService.updates + const aurCount = ArchUpdaterService.aurUpdates + const pacmanTooltip = (pacCount > 0) ? ((pacCount === 1) ? "1 system package" : pacCount + " system packages") : "" + const aurTooltip = (aurCount > 0) ? ((aurCount === 1) ? "1 AUR package" : aurCount + " AUR packages") : "" - return header + "\n\n" + repoBlock + "\n\n" + aurBlock + "\n\nClick to update system" + let tooltip = header + if (pacmanTooltip !== "") { + tooltip += "\n" + pacmanTooltip + } + if (aurTooltip !== "") { + tooltip += "\n" + aurTooltip + } + return tooltip } onClicked: { if (ArchUpdaterService.busy || ArchUpdaterService.aurBusy) { + ToastService.showNotice("ArchUpdater", "Still fetching updates...") return } diff --git a/Modules/Bar/Widgets/Bluetooth.qml b/Modules/Bar/Widgets/Bluetooth.qml index 38c9050..0b8b4f9 100644 --- a/Modules/Bar/Widgets/Bluetooth.qml +++ b/Modules/Bar/Widgets/Bluetooth.qml @@ -20,16 +20,7 @@ NIconButton { colorBorder: Color.transparent colorBorderHover: Color.transparent - icon: { - // Show different icons based on connection status - if (BluetoothService.pairedDevices.length > 0) { - return "bluetooth_connected" - } else if (BluetoothService.discovering) { - return "bluetooth_searching" - } else { - return "bluetooth" - } - } + icon: "bluetooth" tooltipText: "Bluetooth Devices" onClicked: PanelService.getPanel("bluetoothPanel")?.toggle(screen, this) } diff --git a/Modules/Bar/Widgets/NightLight.qml b/Modules/Bar/Widgets/NightLight.qml index 845e85f..5fe70da 100644 --- a/Modules/Bar/Widgets/NightLight.qml +++ b/Modules/Bar/Widgets/NightLight.qml @@ -17,21 +17,19 @@ Item { NPill { id: pill - icon: NightLightService.isActive ? "bedtime" : "bedtime_off" - iconCircleColor: NightLightService.isActive ? Color.mSecondary : Color.mOnSurfaceVariant - collapsedIconColor: NightLightService.isActive ? Color.mOnSecondary : Color.mOnSurface + icon: Settings.data.nightLight.enabled ? "bedtime" : "bedtime_off" + iconCircleColor: Settings.data.nightLight.enabled ? Color.mSecondary : Color.mOnSurfaceVariant + collapsedIconColor: Settings.data.nightLight.enabled ? Color.mOnSecondary : Color.mOnSurface autoHide: false - text: NightLightService.isActive ? "On" : "Off" + text: Settings.data.nightLight.enabled ? "On" : "Off" tooltipText: { if (!Settings.isLoaded || !Settings.data.nightLight.enabled) { return "Night Light: Disabled\nLeft click to open settings.\nRight click to enable." } - var status = NightLightService.isActive ? "Active" : "Inactive (outside schedule)" var intensity = Math.round(Settings.data.nightLight.intensity * 100) - var schedule = Settings.data.nightLight.autoSchedule ? `Schedule: ${Settings.data.nightLight.startTime} - ${Settings.data.nightLight.stopTime}` : "Manual mode" - - return `Intensity: ${intensity}%\n${schedule}\nLeft click to open settings.\nRight click to toggle.` + var schedule = Settings.data.nightLight.autoSchedule ? `Auto schedule` : `Manual: ${Settings.data.nightLight.startTime} - ${Settings.data.nightLight.stopTime}` + return `Night Light: Enabled\nIntensity: ${intensity}%\n${schedule}\nLeft click to open settings.\nRight click to toggle.` } onClicked: { @@ -42,14 +40,11 @@ Item { } onRightClicked: { - // Right click - toggle night light + // Right click - toggle night light (debounced apply handled by service) Settings.data.nightLight.enabled = !Settings.data.nightLight.enabled + NightLightService.apply() } - onWheel: delta => { - var diff = delta > 0 ? 0.05 : -0.05 - Settings.data.nightLight.intensity = Math.max(0, Math.min(1.0, - Settings.data.nightLight.intensity + diff)) - } + // Wheel handler removed to avoid frequent rapid restarts/flicker } } diff --git a/Modules/Bar/Widgets/Volume.qml b/Modules/Bar/Widgets/Volume.qml index e115102..c3c8c3d 100644 --- a/Modules/Bar/Widgets/Volume.qml +++ b/Modules/Bar/Widgets/Volume.qml @@ -14,6 +14,7 @@ Item { // Used to avoid opening the pill on Quickshell startup property bool firstVolumeReceived: false + property int wheelAccumulator: 0 implicitWidth: pill.width implicitHeight: pill.height @@ -59,10 +60,13 @@ Item { tooltipText: "Volume: " + Math.round( AudioService.volume * 100) + "%\nLeft click for advanced settings.\nScroll up/down to change volume." - onWheel: function (angle) { - if (angle > 0) { + onWheel: function (delta) { + wheelAccumulator += delta + if (wheelAccumulator >= 120) { + wheelAccumulator = 0 AudioService.increaseVolume() - } else if (angle < 0) { + } else if (wheelAccumulator <= -120) { + wheelAccumulator = 0 AudioService.decreaseVolume() } } diff --git a/Modules/BluetoothPanel/BluetoothDevicesList.qml b/Modules/BluetoothPanel/BluetoothDevicesList.qml new file mode 100644 index 0000000..9d4f981 --- /dev/null +++ b/Modules/BluetoothPanel/BluetoothDevicesList.qml @@ -0,0 +1,262 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Wayland +import qs.Commons +import qs.Services +import qs.Widgets + +ColumnLayout { + id: root + + property string label: "" + property var model: { + + } + + Layout.fillWidth: true + spacing: Style.marginM * scaling + + NText { + text: root.label + font.pointSize: Style.fontSizeL * scaling + color: Color.mSecondary + font.weight: Style.fontWeightMedium + Layout.fillWidth: true + visible: root.model.length > 0 + } + + Repeater { + Layout.fillWidth: true + model: root.model + visible: BluetoothService.adapter && BluetoothService.adapter.enabled + + Rectangle { + property bool canConnect: BluetoothService.canConnect(modelData) + property bool isBusy: BluetoothService.isDeviceBusy(modelData) + + Layout.fillWidth: true + Layout.preferredHeight: 64 * scaling + (10 * scaling * modelData.batteryAvailable) + radius: Style.radiusM * scaling + + color: { + if (availableDeviceArea.containsMouse && !isBusy) + return Color.mTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mPrimary + + if (modelData.blocked) + return Color.mError + + return Color.mSurfaceVariant + } + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + + RowLayout { + anchors.fill: parent + anchors.margins: Style.marginM * scaling + spacing: Style.marginS * scaling + Layout.alignment: Qt.AlignVCenter + + // One device BT icon + NIcon { + text: BluetoothService.getDeviceIcon(modelData) + font.pointSize: Style.fontSizeXXL * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurface + } + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS * scaling + + // Device name + NText { + text: modelData.name || modelData.deviceName + font.pointSize: Style.fontSizeM * scaling + elide: Text.ElideRight + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurface + } + font.weight: Style.fontWeightMedium + Layout.fillWidth: true + } + + // Signal Strength + RowLayout { + Layout.fillWidth: true + spacing: Style.marginXS * scaling + + // Device signal strength - "Unknown" when not connected + NText { + text: BluetoothService.getSignalStrength(modelData) + font.pointSize: Style.fontSizeXS * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurfaceVariant + } + } + + NIcon { + text: BluetoothService.getSignalIcon(modelData) + font.pointSize: Style.fontSizeXS * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurface + } + visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0 && !modelData.pairing + && !modelData.blocked + } + + NText { + text: (modelData.signalStrength !== undefined + && modelData.signalStrength > 0) ? modelData.signalStrength + "%" : "" + font.pointSize: Style.fontSizeXS * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurface + } + visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0 && !modelData.pairing + && !modelData.blocked + } + } + + NText { + visible: modelData.batteryAvailable + text: BluetoothService.getBattery(modelData) + font.pointSize: Style.fontSizeXS * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurfaceVariant + } + } + } + + // Spacer to push connect button to the right + Item { + Layout.fillWidth: true + } + + // Call to action + Rectangle { + Layout.preferredWidth: 80 * scaling + Layout.preferredHeight: 28 * scaling + radius: Style.radiusM * scaling + visible: (modelData.state !== BluetoothDeviceState.Connecting) + color: Color.transparent + + border.color: { + if (availableDeviceArea.containsMouse) { + return Color.mOnTertiary + } else { + return Color.mPrimary + } + } + border.width: Math.max(1, Style.borderS * scaling) + opacity: canConnect || isBusy ? 1 : 0.5 + + NText { + anchors.centerIn: parent + text: { + if (modelData.pairing) { + return "Pairing..." + } + if (modelData.blocked) { + return "Blocked" + } + if (modelData.paired || modelData.trusted) { + return "Disconnect" + } + return "Connect" + } + font.pointSize: Style.fontSizeXS * scaling + font.weight: Style.fontWeightMedium + color: { + if (availableDeviceArea.containsMouse) { + return Color.mOnTertiary + } else { + return Color.mPrimary + } + } + } + } + } + + MouseArea { + id: availableDeviceArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: canConnect && !isBusy ? Qt.PointingHandCursor : (isBusy ? Qt.BusyCursor : Qt.ArrowCursor) + enabled: canConnect && !isBusy + onClicked: { + if (!modelData || modelData.pairing) { + return + } + + if (modelData.paired || modelData.trusted) { + BluetoothService.disconnectDevice(modelData) + } else { + BluetoothService.connectDeviceWithTrust(modelData) + } + } + } + } + } +} diff --git a/Modules/BluetoothPanel/BluetoothPanel.qml b/Modules/BluetoothPanel/BluetoothPanel.qml index 3518d9b..77ed79c 100644 --- a/Modules/BluetoothPanel/BluetoothPanel.qml +++ b/Modules/BluetoothPanel/BluetoothPanel.qml @@ -68,259 +68,56 @@ NPanel { } ScrollView { - id: scrollView - Layout.fillWidth: true Layout.fillHeight: true - clip: true ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ScrollBar.vertical.policy: ScrollBar.AsNeeded + clip: true + contentWidth: availableWidth - // Available devices - Column { - id: column - + ColumnLayout { + visible: BluetoothService.adapter && BluetoothService.adapter.enabled width: parent.width spacing: Style.marginM * scaling - visible: BluetoothService.adapter && BluetoothService.adapter.enabled - RowLayout { - width: parent.width - spacing: Style.marginM * scaling - - NText { - text: "Available Devices" - font.pointSize: Style.fontSizeL * scaling - color: Color.mOnSurface - font.weight: Style.fontWeightMedium - } - } - - Repeater { + // Connected devices + BluetoothDevicesList { + label: "Connected devices" model: { - if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) + if (!BluetoothService.adapter || !Bluetooth.devices) return [] var filtered = Bluetooth.devices.values.filter(dev => { - return dev && !dev.paired && !dev.pairing && !dev.blocked - && (dev.signalStrength === undefined - || dev.signalStrength > 0) + return dev && !dev.blocked && (dev.paired || dev.trusted) }) return BluetoothService.sortDevices(filtered) } - - Rectangle { - property bool canConnect: BluetoothService.canConnect(modelData) - property bool isBusy: BluetoothService.isDeviceBusy(modelData) - - width: parent.width - height: 70 - radius: Style.radiusM * scaling - color: { - if (availableDeviceArea.containsMouse && !isBusy) - return Color.mTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mPrimary - - if (modelData.blocked) - return Color.mError - - return Color.mSurfaceVariant - } - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS * scaling) - - Row { - anchors.left: parent.left - anchors.leftMargin: Style.marginM * scaling - anchors.verticalCenter: parent.verticalCenter - spacing: Style.marginS * scaling - - // One device BT icon - NIcon { - text: BluetoothService.getDeviceIcon(modelData) - font.pointSize: Style.fontSizeXXL * scaling - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - anchors.verticalCenter: parent.verticalCenter - } - - Column { - spacing: Style.marginXXS * scaling - anchors.verticalCenter: parent.verticalCenter - - // One device name - NText { - text: modelData.name || modelData.deviceName - font.pointSize: Style.fonttSizeMedium * scaling - elide: Text.ElideRight - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - font.weight: Style.fontWeightMedium - } - - Row { - spacing: Style.marginXS * scaling - - Row { - spacing: Style.marginS * spacing - - // One device signal strength - "Unknown" when not connected - NText { - text: { - if (modelData.pairing) - return "Pairing..." - - if (modelData.blocked) - return "Blocked" - - return BluetoothService.getSignalStrength(modelData) - } - font.pointSize: Style.fontSizeXS * scaling - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - } - - NIcon { - text: BluetoothService.getSignalIcon(modelData) - font.pointSize: Style.fontSizeXS * scaling - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0 - && !modelData.pairing && !modelData.blocked - } - - NText { - text: (modelData.signalStrength !== undefined - && modelData.signalStrength > 0) ? modelData.signalStrength + "%" : "" - font.pointSize: Style.fontSizeXS * scaling - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0 - && !modelData.pairing && !modelData.blocked - } - } - } - } - } - - Rectangle { - width: 80 * scaling - height: 28 * scaling - radius: Style.radiusM * scaling - anchors.right: parent.right - anchors.rightMargin: Style.marginM * scaling - anchors.verticalCenter: parent.verticalCenter - visible: modelData.state !== BluetoothDeviceState.Connecting - color: Color.transparent - - border.color: { - if (availableDeviceArea.containsMouse) { - return Color.mOnTertiary - } else { - return Color.mPrimary - } - } - border.width: Math.max(1, Style.borderS * scaling) - opacity: canConnect || isBusy ? 1 : 0.5 - - // On device connect button - NText { - anchors.centerIn: parent - text: { - if (modelData.pairing) - return "Pairing..." - - if (modelData.blocked) - return "Blocked" - - return "Connect" - } - font.pointSize: Style.fontSizeXS * scaling - font.weight: Style.fontWeightMedium - color: { - if (availableDeviceArea.containsMouse) { - return Color.mOnTertiary - } else { - return Color.mPrimary - } - } - } - } - - MouseArea { - id: availableDeviceArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: canConnect && !isBusy ? Qt.PointingHandCursor : (isBusy ? Qt.BusyCursor : Qt.ArrowCursor) - enabled: canConnect && !isBusy - onClicked: { - if (modelData) - BluetoothService.connectDeviceWithTrust(modelData) - } - } - } + Layout.fillWidth: true } - // Fallback if nothing available - Column { - width: parent.width + // Available devices + BluetoothDevicesList { + label: "Available devices" + model: { + if (!BluetoothService.adapter || !Bluetooth.devices) + return [] + + var filtered = Bluetooth.devices.values.filter(dev => { + return dev && !dev.blocked && !dev.paired && !dev.trusted + }) + return BluetoothService.sortDevices(filtered) + } + Layout.fillWidth: true + } + + // Fallback + ColumnLayout { + Layout.fillWidth: true spacing: Style.marginM * scaling visible: { - if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) + if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) { return false + } var availableCount = Bluetooth.devices.values.filter(dev => { return dev && !dev.paired && !dev.pairing @@ -328,18 +125,17 @@ NPanel { && (dev.signalStrength === undefined || dev.signalStrength > 0) }).length - return availableCount === 0 + return (availableCount === 0) } - Row { - anchors.horizontalCenter: parent.horizontalCenter + RowLayout { + Layout.alignment: Qt.AlignHCenter spacing: Style.marginM * scaling NIcon { text: "sync" font.pointSize: Style.fontSizeXLL * 1.5 * scaling color: Color.mPrimary - anchors.verticalCenter: parent.verticalCenter RotationAnimation on rotation { running: true @@ -355,7 +151,6 @@ NPanel { font.pointSize: Style.fontSizeL * scaling color: Color.mOnSurface font.weight: Style.fontWeightMedium - anchors.verticalCenter: parent.verticalCenter } } @@ -363,36 +158,15 @@ NPanel { text: "Make sure your device is in pairing mode" font.pointSize: Style.fontSizeM * scaling color: Color.mOnSurfaceVariant - anchors.horizontalCenter: parent.horizontalCenter + Layout.alignment: Qt.AlignHCenter } } - NText { - text: "No devices found. Put your device in pairing mode and click Start Scanning." - font.pointSize: Style.fontSizeM * scaling - color: Color.mOnSurfaceVariant - visible: { - if (!BluetoothService.adapter || !Bluetooth.devices) - return true - - var availableCount = Bluetooth.devices.values.filter(dev => { - return dev && !dev.paired && !dev.pairing - && !dev.blocked - && (dev.signalStrength === undefined - || dev.signalStrength > 0) - }).length - return availableCount === 0 && !BluetoothService.adapter.discovering - } - wrapMode: Text.WordWrap - width: parent.width - horizontalAlignment: Text.AlignHCenter + Item { + Layout.fillHeight: true } } } - // This item takes up all the remaining vertical space. - Item { - Layout.fillHeight: true - } } } } diff --git a/Modules/IPC/IPCManager.qml b/Modules/IPC/IPCManager.qml index 78ed64b..771aea4 100644 --- a/Modules/IPC/IPCManager.qml +++ b/Modules/IPC/IPCManager.qml @@ -99,6 +99,24 @@ Item { } } + IpcHandler { + target: "volume" + function increase() { + AudioService.increaseVolume() + } + function decrease() { + AudioService.decreaseVolume() + } + function muteOutput() { + AudioService.setMuted(!AudioService.muted) + } + function muteInput() { + if (AudioService.source?.ready && AudioService.source?.audio) { + AudioService.source.audio.muted = !AudioService.source.audio.muted + } + } + } + IpcHandler { target: "powerPanel" function toggle() { diff --git a/Modules/NightLight/NightLightOverlay.qml b/Modules/NightLight/NightLightOverlay.qml deleted file mode 100644 index 8bb74bc..0000000 --- a/Modules/NightLight/NightLightOverlay.qml +++ /dev/null @@ -1,46 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Wayland -import qs.Commons -import qs.Services - -Variants { - model: Quickshell.screens - - delegate: Loader { - required property ShellScreen modelData - readonly property real scaling: ScalingService.scale(modelData) - - active: NightLightService.isActive - - sourceComponent: PanelWindow { - screen: modelData - color: Color.transparent - anchors { - top: true - bottom: true - left: true - right: true - } - - // Ensure a full click through - mask: Region {} - - WlrLayershell.layer: WlrLayershell.Overlay - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - WlrLayershell.namespace: "noctalia-nightlight" - - Rectangle { - anchors.fill: parent - color: NightLightService.overlayColor - - Behavior on color { - ColorAnimation { - duration: Style.animationSlow - } - } - } - } - } -} diff --git a/Modules/SettingsPanel/SettingsPanel.qml b/Modules/SettingsPanel/SettingsPanel.qml index a1992f9..440daec 100644 --- a/Modules/SettingsPanel/SettingsPanel.qml +++ b/Modules/SettingsPanel/SettingsPanel.qml @@ -301,6 +301,7 @@ NPanel { ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ScrollBar.vertical.policy: ScrollBar.AsNeeded padding: Style.marginL * scaling + clip: true Loader { active: true diff --git a/Modules/SettingsPanel/Tabs/AudioTab.qml b/Modules/SettingsPanel/Tabs/AudioTab.qml index 1a6f4b9..fa0c0c0 100644 --- a/Modules/SettingsPanel/Tabs/AudioTab.qml +++ b/Modules/SettingsPanel/Tabs/AudioTab.qml @@ -81,6 +81,54 @@ ColumnLayout { } } + // Input Volume + ColumnLayout { + spacing: Style.marginS * scaling + Layout.fillWidth: true + Layout.topMargin: Style.marginM * scaling + + NLabel { + label: "Input Volume" + description: "Microphone input volume level." + } + + RowLayout { + NSlider { + Layout.fillWidth: true + from: 0 + to: 1.0 + value: AudioService.inputVolume + stepSize: 0.01 + onMoved: { + AudioService.setInputVolume(value) + } + } + + NText { + text: Math.floor(AudioService.inputVolume * 100) + "%" + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: Style.marginS * scaling + color: Color.mOnSurface + } + } + } + + // Input Mute Toggle + ColumnLayout { + spacing: Style.marginS * scaling + Layout.fillWidth: true + Layout.topMargin: Style.marginM * scaling + + NToggle { + label: "Mute Audio Input" + description: "Mute or unmute the default audio input (microphone)." + checked: AudioService.inputMuted + onToggled: checked => { + AudioService.setInputMuted(checked) + } + } + } + // Volume Step Size ColumnLayout { spacing: Style.marginS * scaling @@ -216,8 +264,6 @@ ColumnLayout { } // Preferred player (persistent) NTextInput { - Layout.fillWidth: true - Layout.alignment: Qt.AlignTop label: "Preferred Player" description: "Substring to match MPRIS player (identity/bus/desktop)." placeholderText: "e.g. spotify, vlc, mpv" @@ -239,8 +285,6 @@ ColumnLayout { NTextInput { id: blacklistInput - Layout.fillWidth: true - Layout.alignment: Qt.AlignTop label: "Blacklist player" description: "Substring, e.g. plex, shim, mpv." placeholderText: "type substring and press +" diff --git a/Modules/SettingsPanel/Tabs/ColorSchemeTab.qml b/Modules/SettingsPanel/Tabs/ColorSchemeTab.qml index 5cde84d..cc0c599 100644 --- a/Modules/SettingsPanel/Tabs/ColorSchemeTab.qml +++ b/Modules/SettingsPanel/Tabs/ColorSchemeTab.qml @@ -344,25 +344,24 @@ ColumnLayout { visible: Settings.data.colorSchemes.useWallpaperColors ColumnLayout { - spacing: Style.marginS * scaling - Layout.fillWidth: true + spacing: Style.marginS * scaling + Layout.fillWidth: true - NText { - text: "Matugen Templates" - font.pointSize: Style.fontSizeXXL * scaling - font.weight: Style.fontWeightBold - color: Color.mSecondary - } - - NText { - text: "Select which external components Matugen should apply theming to." - font.pointSize: Style.fontSizeM * scaling - color: Color.mOnSurfaceVariant - Layout.fillWidth: true - wrapMode: Text.WordWrap - } + NText { + text: "Matugen Templates" + font.pointSize: Style.fontSizeXXL * scaling + font.weight: Style.fontWeightBold + color: Color.mSecondary } + NText { + text: "Select which external components Matugen should apply theming to." + font.pointSize: Style.fontSizeM * scaling + color: Color.mOnSurfaceVariant + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } NCheckbox { label: "GTK 4 (libadwaita)" diff --git a/Modules/SettingsPanel/Tabs/DisplayTab.qml b/Modules/SettingsPanel/Tabs/DisplayTab.qml index 3236bcb..5dfcbfe 100644 --- a/Modules/SettingsPanel/Tabs/DisplayTab.qml +++ b/Modules/SettingsPanel/Tabs/DisplayTab.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls import Quickshell +import Quickshell.Io import qs.Commons import qs.Services import qs.Widgets @@ -27,6 +28,27 @@ ColumnLayout { } } + // Check for wlsunset availability when enabling Night Light + Process { + id: wlsunsetCheck + command: ["which", "wlsunset"] + running: false + + onExited: function (exitCode) { + if (exitCode === 0) { + Settings.data.nightLight.enabled = true + NightLightService.apply() + ToastService.showNotice("Night Light", "Enabled") + } else { + Settings.data.nightLight.enabled = false + ToastService.showWarning("Night Light", "wlsunset not installed") + } + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + // Helper functions to update arrays immutably function addMonitor(list, name) { const arr = (list || []).slice() @@ -52,7 +74,6 @@ ColumnLayout { color: Color.mOnSurfaceVariant wrapMode: Text.WordWrap Layout.fillWidth: true - Layout.preferredWidth: parent.width - (Style.marginL * 2 * scaling) } ColumnLayout { @@ -231,7 +252,6 @@ ColumnLayout { color: Color.mOnSurfaceVariant wrapMode: Text.WordWrap Layout.fillWidth: true - Layout.preferredWidth: parent.width - (Style.marginL * 2 * scaling) } } @@ -239,7 +259,16 @@ ColumnLayout { label: "Enable Night Light" description: "Apply a warm color filter to reduce blue light emission." checked: Settings.data.nightLight.enabled - onToggled: checked => Settings.data.nightLight.enabled = checked + onToggled: checked => { + if (checked) { + // Verify wlsunset exists before enabling + wlsunsetCheck.running = true + } else { + Settings.data.nightLight.enabled = false + NightLightService.apply() + ToastService.showNotice("Night Light", "Disabled") + } + } } // Intensity settings @@ -247,7 +276,7 @@ ColumnLayout { visible: Settings.data.nightLight.enabled NLabel { label: "Intensity" - description: "Higher values create warmer light." + description: "Higher values create warmer tones." } RowLayout { spacing: Style.marginS * scaling @@ -257,7 +286,10 @@ ColumnLayout { to: 1 stepSize: 0.01 value: Settings.data.nightLight.intensity - onMoved: Settings.data.nightLight.intensity = value + onMoved: { + Settings.data.nightLight.intensity = value + NightLightService.apply() + } Layout.fillWidth: true Layout.minimumWidth: 150 * scaling } @@ -271,11 +303,74 @@ ColumnLayout { } } + // Temperature + ColumnLayout { + spacing: Style.marginXS * scaling + Layout.alignment: Qt.AlignVCenter + + NLabel { + label: "Color temperature" + description: "Select two temperatures in Kelvin" + } + + RowLayout { + visible: Settings.data.nightLight.enabled + spacing: Style.marginM * scaling + Layout.fillWidth: false + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + + NText { + text: "Low" + font.pointSize: Style.fontSizeM * scaling + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignVCenter + } + + NTextInput { + text: Settings.data.nightLight.lowTemp.toString() + inputMethodHints: Qt.ImhDigitsOnly + Layout.alignment: Qt.AlignVCenter + onEditingFinished: { + var v = parseInt(text) + if (!isNaN(v)) { + Settings.data.nightLight.lowTemp = Math.max(1000, Math.min(6500, v)) + NightLightService.apply() + } + } + } + + Item {} + + NText { + text: "High" + font.pointSize: Style.fontSizeM * scaling + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignVCenter + } + NTextInput { + text: Settings.data.nightLight.highTemp.toString() + inputMethodHints: Qt.ImhDigitsOnly + Layout.alignment: Qt.AlignVCenter + onEditingFinished: { + var v = parseInt(text) + if (!isNaN(v)) { + Settings.data.nightLight.highTemp = Math.max(1000, Math.min(10000, v)) + NightLightService.apply() + } + } + } + } + } + NToggle { label: "Auto Schedule" description: "Automatically enable night light based on time schedule." checked: Settings.data.nightLight.autoSchedule - onToggled: checked => Settings.data.nightLight.autoSchedule = checked + onToggled: checked => { + Settings.data.nightLight.autoSchedule = checked + NightLightService.apply() + } visible: Settings.data.nightLight.enabled } @@ -303,7 +398,10 @@ ColumnLayout { model: timeOptions currentKey: Settings.data.nightLight.startTime placeholder: "Select start time" - onSelected: key => Settings.data.nightLight.startTime = key + onSelected: key => { + Settings.data.nightLight.startTime = key + NightLightService.apply() + } preferredWidth: 120 * scaling } @@ -319,7 +417,10 @@ ColumnLayout { model: timeOptions currentKey: Settings.data.nightLight.stopTime placeholder: "Select stop time" - onSelected: key => Settings.data.nightLight.stopTime = key + onSelected: key => { + Settings.data.nightLight.stopTime = key + NightLightService.apply() + } preferredWidth: 120 * scaling } } diff --git a/Modules/SettingsPanel/Tabs/GeneralTab.qml b/Modules/SettingsPanel/Tabs/GeneralTab.qml index 473a5ce..b80faa2 100644 --- a/Modules/SettingsPanel/Tabs/GeneralTab.qml +++ b/Modules/SettingsPanel/Tabs/GeneralTab.qml @@ -25,10 +25,9 @@ ColumnLayout { NTextInput { label: "Profile Picture" - description: "Your profile picture displayed in various places throughout the shell." + description: "Your profile picture that appears throughout the interface." text: Settings.data.general.avatarImage placeholderText: "/home/user/.face" - Layout.fillWidth: true onEditingFinished: { Settings.data.general.avatarImage = text } diff --git a/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml b/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml index 2a000c1..900b971 100644 --- a/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml +++ b/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml @@ -24,7 +24,8 @@ ColumnLayout { onEditingFinished: { Settings.data.screenRecorder.directory = text } - Layout.fillWidth: true + + Layout.maximumWidth: 420 * scaling } ColumnLayout { diff --git a/Modules/SettingsPanel/Tabs/TimeWeatherTab.qml b/Modules/SettingsPanel/Tabs/TimeWeatherTab.qml index 519177f..50ea298 100644 --- a/Modules/SettingsPanel/Tabs/TimeWeatherTab.qml +++ b/Modules/SettingsPanel/Tabs/TimeWeatherTab.qml @@ -10,6 +10,7 @@ ColumnLayout { // Location section RowLayout { + Layout.fillWidth: true spacing: Style.marginL * scaling NTextInput { @@ -25,6 +26,7 @@ ColumnLayout { LocationService.resetWeather() } } + Layout.maximumWidth: 420 * scaling } NText { diff --git a/Modules/SettingsPanel/Tabs/WallpaperTab.qml b/Modules/SettingsPanel/Tabs/WallpaperTab.qml index 6f85cb0..e96b5ef 100644 --- a/Modules/SettingsPanel/Tabs/WallpaperTab.qml +++ b/Modules/SettingsPanel/Tabs/WallpaperTab.qml @@ -35,10 +35,10 @@ ColumnLayout { label: "Wallpaper Directory" description: "Path to your wallpaper directory." text: Settings.data.wallpaper.directory - Layout.fillWidth: true onEditingFinished: { Settings.data.wallpaper.directory = text } + Layout.maximumWidth: 420 * scaling } NDivider { @@ -79,12 +79,7 @@ ColumnLayout { NText { // Show friendly H:MM format from current settings - text: { - const s = Settings.data.wallpaper.randomInterval - const h = Math.floor(s / 3600) - const m = Math.floor((s % 3600) / 60) - return (h > 0 ? (h + "h ") : "") + (m > 0 ? (m + "m") : (h === 0 ? "0m" : "")) - } + text: Time.formatVagueHumanReadableDuration(Settings.data.wallpaper.randomInterval) Layout.alignment: Qt.AlignBottom | Qt.AlignRight } } @@ -284,14 +279,15 @@ ColumnLayout { NTextInput { label: "Custom Interval" - description: "Enter time as HH:MM (e.g., 1:30)." + description: "Enter time as HH:MM (e.g., 01:30)." + inputMaxWidth: 100 * scaling text: { const s = Settings.data.wallpaper.randomInterval const h = Math.floor(s / 3600) const m = Math.floor((s % 3600) / 60) return h + ":" + (m < 10 ? ("0" + m) : m) } - Layout.fillWidth: true + onEditingFinished: { const m = text.trim().match(/^(\d{1,2}):(\d{2})$/) if (m) { diff --git a/README.md b/README.md index f8649cf..b7d81b2 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ Features a modern modular architecture with a status bar, notification system, c ## Preview -![Launcher](https://assets.noctalia.dev/screenshots/launcher.png) +![Launcher](https://assets.noctalia.dev/screenshots/launcher.png?v=2) -![SettingsPanel](https://assets.noctalia.dev/screenshots/settings-panel.png) +![SettingsPanel](https://assets.noctalia.dev/screenshots/settings-panel.png?v=2) -![SidePanel](https://assets.noctalia.dev/screenshots/light-mode.png) +![SidePanel](https://assets.noctalia.dev/screenshots/light-mode.png?v=2) --- @@ -70,7 +70,6 @@ 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) -- `xdg-desktop-portal-gnome` - Desktop integration (or alternative portal) ### Optional @@ -79,6 +78,7 @@ Features a modern modular architecture with a status bar, notification system, c - `swww` - Wallpaper animations and effects - `matugen` - Material You color scheme generation - `cava` - Audio visualizer component +- `wlsunset` - To be able to use NightLight > There are 2 more optional dependencies. > Any `polkit agent` to be able to use the ArchUpdater widget. @@ -152,14 +152,12 @@ Alternatively, you can add it to your NixOS configuration or flake: quickshell = { url = "github:outfoxxed/quickshell"; inputs.nixpkgs.follows = "nixpkgs"; + inputs.quickshell.follows = "quickshell" }; }; outputs = { self, nixpkgs, noctalia, quickshell, ... }: - let - system = "x86_64-linux"; - pkgs = import nixpkgs { inherit system; }; - in { + { nixosConfigurations.my-host = nixpkgs.lib.nixosSystem { modules = [ ./configuration.nix @@ -173,8 +171,8 @@ Alternatively, you can add it to your NixOS configuration or flake: ```nix { environment.systemPackages = with pkgs; [ - noctalia.packages.${system}.default - quickshell.packages.${system}.default + inputs.noctalia.packages.${system}.default + inputs.quickshell.packages.${system}.default ]; } ``` @@ -196,6 +194,10 @@ The following commands apply to the Nix flake and also the AUR package installat | Open Calculator | `noctalia-shell ipc call launcher calculator` | | Increase Brightness | `noctalia-shell ipc call brightness increase` | | Decrease Brightness | `noctalia-shell ipc call brightness decrease` | +| Increase Output Volume | `noctalia-shell ipc call volume increase` | +| Decrease Output Volume | `noctalia-shell ipc call volume decrease` | +| Toggle Mute Audio Output | `noctalia-shell ipc call volume muteOutput` | +| Toggle Mute Audio Input | `noctalia-shell ipc call volume muteInput` | | Toggle Power Panel | `noctalia-shell ipc call powerPanel toggle` | | Toggle Idle Inhibitor | `noctalia-shell ipc call idleInhibitor toggle` | | Toggle Settings Window | `noctalia-shell ipc call settings toggle` | diff --git a/Services/ArchUpdaterService.qml b/Services/ArchUpdaterService.qml index 84c8228..de3b3af 100644 --- a/Services/ArchUpdaterService.qml +++ b/Services/ArchUpdaterService.qml @@ -142,28 +142,40 @@ Singleton { return updateInProgress = true + // Split selected packages by source - const repoPkgs = selectedPackages.filter(pkg => { - const repoPkg = repoPackages.find(p => p.name === pkg) - return repoPkg && repoPkg.source === "repo" - }) - const aurPkgs = selectedPackages.filter(pkg => { - const aurPkg = aurPackages.find(p => p.name === pkg) - return aurPkg && aurPkg.source === "aur" - }) + const repoPkgs = [] + const aurPkgs = [] + + for (const pkgName of selectedPackages) { + const repoPkg = repoPackages.find(p => p.name === pkgName) + if (repoPkg && repoPkg.source === "repo") { + repoPkgs.push(pkgName) + } + + const aurPkg = aurPackages.find(p => p.name === pkgName) + if (aurPkg && aurPkg.source === "aur") { + aurPkgs.push(pkgName) + } + } // Update repo packages if (repoPkgs.length > 0) { const repoCommand = ["pkexec", "pacman", "-S", "--noconfirm"].concat(repoPkgs) + Logger.log("ArchUpdater", "Running repo command:", repoCommand.join(" ")) Quickshell.execDetached(repoCommand) } // Update AUR packages if (aurPkgs.length > 0) { - const aurCommand = ["sh", "-c", `command -v yay >/dev/null 2>&1 && yay -S ${aurPkgs.join( - ' ')} --noconfirm || command -v paru >/dev/null 2>&1 && paru -S ${aurPkgs.join( - ' ')} --noconfirm || true`] - Quickshell.execDetached(aurCommand) + const aurHelper = getAurHelper() + if (aurHelper) { + const aurCommand = [aurHelper, "-S", "--noconfirm"].concat(aurPkgs) + Logger.log("ArchUpdater", "Running AUR command:", aurCommand.join(" ")) + Quickshell.execDetached(aurCommand) + } else { + Logger.warn("ArchUpdater", "No AUR helper found for packages:", aurPkgs.join(", ")) + } } // Clear selection and refresh @@ -172,6 +184,22 @@ Singleton { refreshAfterUpdate() } + // Helper function to detect AUR helper + function getAurHelper() { + // Check for yay first, then paru + const yayCheck = Quickshell.exec("command -v yay", true) + if (yayCheck.exitCode === 0 && yayCheck.stdout.trim()) { + return "yay" + } + + const paruCheck = Quickshell.exec("command -v paru", true) + if (paruCheck.exitCode === 0 && paruCheck.stdout.trim()) { + return "paru" + } + + return null + } + // Package selection functions function togglePackageSelection(packageName) { const index = selectedPackages.indexOf(packageName) diff --git a/Services/AudioService.qml b/Services/AudioService.qml index 0dd6fc9..c6ec05c 100644 --- a/Services/AudioService.qml +++ b/Services/AudioService.qml @@ -35,6 +35,13 @@ Singleton { readonly property alias muted: root._muted property bool _muted: !!sink?.audio?.muted + // Input volume [0..1] is readonly from outside + readonly property alias inputVolume: root._inputVolume + property real _inputVolume: source?.audio?.volume ?? 0 + + readonly property alias inputMuted: root._inputMuted + property bool _inputMuted: !!source?.audio?.muted + readonly property real stepVolume: Settings.data.audio.volumeStep / 100.0 PwObjectTracker { @@ -58,6 +65,23 @@ Singleton { } } + Connections { + target: source?.audio ? source?.audio : null + + function onVolumeChanged() { + var vol = (source?.audio.volume ?? 0) + if (isNaN(vol)) { + vol = 0 + } + root._inputVolume = vol + } + + function onMutedChanged() { + root._inputMuted = (source?.audio.muted ?? true) + Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted) + } + } + function increaseVolume() { setVolume(volume + stepVolume) } @@ -85,6 +109,24 @@ Singleton { } } + function setInputVolume(newVolume: real) { + if (source?.ready && source?.audio) { + // Clamp it accordingly + source.audio.muted = false + source.audio.volume = Math.max(0, Math.min(1, newVolume)) + } else { + Logger.warn("AudioService", "No source available") + } + } + + function setInputMuted(muted: bool) { + if (source?.ready && source?.audio) { + source.audio.muted = muted + } else { + Logger.warn("AudioService", "No source available") + } + } + function setAudioSink(newSink: PwNode): void { Pipewire.preferredDefaultAudioSink = newSink } diff --git a/Services/BluetoothService.qml b/Services/BluetoothService.qml index aac2b53..368534c 100644 --- a/Services/BluetoothService.qml +++ b/Services/BluetoothService.qml @@ -13,17 +13,17 @@ Singleton { readonly property bool discovering: (adapter && adapter.discovering) ?? false readonly property var devices: adapter ? adapter.devices : null readonly property var pairedDevices: { - if (!adapter || !adapter.devices) - return [] - + if (!adapter || !adapter.devices) { + return [] + } return adapter.devices.values.filter(dev => { return dev && (dev.paired || dev.trusted) }) } readonly property var allDevicesWithBattery: { - if (!adapter || !adapter.devices) - return [] - + if (!adapter || !adapter.devices) { + return [] + } return adapter.devices.values.filter(dev => { return dev && dev.batteryAvailable && dev.battery > 0 }) @@ -49,34 +49,36 @@ Singleton { } function getDeviceIcon(device) { - if (!device) + if (!device) { return "bluetooth" + } var name = (device.name || device.deviceName || "").toLowerCase() var icon = (device.icon || "").toLowerCase() if (icon.includes("headset") || icon.includes("audio") || name.includes("headphone") || name.includes("airpod") - || name.includes("headset") || name.includes("arctis")) + || name.includes("headset") || name.includes("arctis")) { return "headset" + } - if (icon.includes("mouse") || name.includes("mouse")) + if (icon.includes("mouse") || name.includes("mouse")) { return "mouse" - - if (icon.includes("keyboard") || name.includes("keyboard")) + } + if (icon.includes("keyboard") || name.includes("keyboard")) { return "keyboard" - + } if (icon.includes("phone") || name.includes("phone") || name.includes("iphone") || name.includes("android") - || name.includes("samsung")) + || name.includes("samsung")) { return "smartphone" - - if (icon.includes("watch") || name.includes("watch")) + } + if (icon.includes("watch") || name.includes("watch")) { return "watch" - - if (icon.includes("speaker") || name.includes("speaker")) + } + if (icon.includes("speaker") || name.includes("speaker")) { return "speaker" - - if (icon.includes("display") || name.includes("tv")) + } + if (icon.includes("display") || name.includes("tv")) { return "tv" - + } return "bluetooth" } @@ -88,60 +90,91 @@ Singleton { } function getSignalStrength(device) { - if (!device || device.signalStrength === undefined || device.signalStrength <= 0) - return "Unknown" - + if (device.pairing) { + return "Pairing..." + } + if (device.blocked) { + return "Blocked" + } + if (!device || device.signalStrength === undefined || device.signalStrength <= 0) { + return "Signal: Unknown" + } var signal = device.signalStrength - if (signal >= 80) - return "Excellent" + if (signal >= 80) { + return "Signal: Excellent" + } + if (signal >= 60) { + return "Signal: Good" + } + if (signal >= 40) { + return "Signal: Fair" + } + if (signal >= 20) { + return "Signal: Poor" + } + return "Signal: Very Poor" + } - if (signal >= 60) - return "Good" - - if (signal >= 40) - return "Fair" - - if (signal >= 20) - return "Poor" - - return "Very Poor" + function getBattery(device) { + return `Battery: ${Math.round(device.battery * 100)}` } function getSignalIcon(device) { - if (!device || device.signalStrength === undefined || device.signalStrength <= 0) + if (!device || device.signalStrength === undefined || device.signalStrength <= 0) { return "signal_cellular_null" - + } var signal = device.signalStrength - if (signal >= 80) + if (signal >= 80) { return "signal_cellular_4_bar" - - if (signal >= 60) + } + if (signal >= 60) { return "signal_cellular_3_bar" - - if (signal >= 40) + } + if (signal >= 40) { return "signal_cellular_2_bar" - - if (signal >= 20) + } + if (signal >= 20) { return "signal_cellular_1_bar" - + } return "signal_cellular_0_bar" } function isDeviceBusy(device) { - if (!device) + if (!device) { return false + } + return device.pairing || device.state === BluetoothDeviceState.Disconnecting || device.state === BluetoothDeviceState.Connecting } function connectDeviceWithTrust(device) { - if (!device) + if (!device) { return + } device.trusted = true device.connect() } + function disconnectDevice(device) { + if (!device) { + return + } + + device.trusted = false + device.disconnect() + } + + function forgetDevice(device) { + if (!device) { + return + } + + device.trusted = false + device.forget() + } + function setBluetoothEnabled(enabled) { if (!adapter) { Logger.warn("Bluetooth", "No adapter available") diff --git a/Services/NightLightService.qml b/Services/NightLightService.qml index f94e409..4395b76 100644 --- a/Services/NightLightService.qml +++ b/Services/NightLightService.qml @@ -4,63 +4,129 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import qs.Services Singleton { id: root // Night Light properties - directly bound to settings readonly property var params: Settings.data.nightLight + // Deprecated overlay flag removed; service only manages wlsunset now + property bool isActive: false + property bool isRunning: false + property string lastCommand: "" + property var nextCommand: [] - // Computed properties - readonly property color overlayColor: params.enabled ? calculateOverlayColor() : "transparent" - property bool isActive: params.enabled && (params.autoSchedule ? isWithinSchedule() : true) + Component.onCompleted: apply() - Component.onCompleted: { - Logger.log("NightLight", "Service started") - } - - function calculateOverlayColor() { - if (!isActive) { - return "transparent" - } - - // More vibrant color formula - stronger effect at high warmth - var red = 1.0 - var green = 1.0 - (0.43 * params.intensity) - var blue = 1.0 - (0.84 * params.intensity) - var alpha = (params.intensity * 0.25) // Higher alpha for more noticeable effect - - return Qt.rgba(red, green, blue, alpha) - } - - function isWithinSchedule() { - if (!params.autoSchedule) { - return true - } - - var now = new Date() - var currentTime = now.getHours() * 60 + now.getMinutes() - - var startParts = params.startTime.split(":") - var stopParts = params.stopTime.split(":") - var startMinutes = parseInt(startParts[0]) * 60 + parseInt(startParts[1]) - var stopMinutes = parseInt(stopParts[0]) * 60 + parseInt(stopParts[1]) - - // Handle overnight schedule (e.g., 20:00 to 07:00) - if (stopMinutes < startMinutes) { - return currentTime >= startMinutes || currentTime <= stopMinutes + function buildCommand() { + var cmd = ["wlsunset"] + // Use user-configured temps; if intensity is used, bias lowTemp towards user low + var i = Math.max(0, Math.min(1, params.intensity)) + var loCfg = params.lowTemp || 3500 + var hiCfg = params.highTemp || 6500 + var lowTemp = Math.round(hiCfg - (hiCfg - loCfg) * Math.pow(i, 0.6)) + cmd.push("-t", lowTemp.toString()) + cmd.push("-T", hiCfg.toString()) + if (params.autoSchedule && LocationService.data.coordinatesReady && LocationService.data.stableLatitude !== "" + && LocationService.data.stableLongitude !== "") { + cmd.push("-l", LocationService.data.stableLatitude) + cmd.push("-L", LocationService.data.stableLongitude) } else { - return currentTime >= startMinutes && currentTime <= stopMinutes + // Manual schedule + if (params.startTime && params.stopTime) { + cmd.push("-S", params.startTime) + cmd.push("-s", params.stopTime) + } + // Optional: do not pass duration, use wlsunset defaults + } + return cmd + } + + function stopIfRunning() { + // Best-effort stop; wlsunset runs as foreground, so pkill is simplest + Quickshell.execDetached(["pkill", "-x", "wlsunset"]) + isRunning = false + } + + function apply() { + if (!params.enabled) { + // Disable immediately + debounceStart.stop() + nextCommand = [] + stopIfRunning() + return + } + // Debounce rapid changes (slider) + nextCommand = buildCommand() + lastCommand = nextCommand.join(" ") + stopIfRunning() + debounceStart.restart() + } + + // Observe setting changes and location readiness + Connections { + target: Settings.data.nightLight + function onEnabledChanged() { + apply() + } + function onIntensityChanged() { + apply() + } + function onAutoScheduleChanged() { + apply() + } + function onStartTimeChanged() { + apply() + } + function onStopTimeChanged() { + apply() } } - // Timer to check schedule changes + Connections { + target: LocationService.data + function onCoordinatesReadyChanged() { + if (params.enabled && params.autoSchedule) + apply() + } + function onStableLatitudeChanged() { + if (params.enabled && params.autoSchedule) + apply() + } + function onStableLongitudeChanged() { + if (params.enabled && params.autoSchedule) + apply() + } + } + + // Foreground process runner + Process { + id: runner + running: false + onStarted: { + isRunning = true + Logger.log("NightLight", "Started wlsunset:", root.lastCommand) + } + onExited: function (code, status) { + isRunning = false + Logger.log("NightLight", "wlsunset exited:", code, status) + // Do not auto-restart here; debounceStart handles starts + } + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + // Debounce timer to avoid flicker when moving sliders Timer { - interval: 60000 // Check every minute - running: params.enabled && params.autoSchedule - repeat: true + id: debounceStart + interval: 300 + repeat: false onTriggered: { - isActive = isWithinSchedule() + if (params.enabled && nextCommand.length > 0) { + runner.command = nextCommand + runner.running = true + } } } } diff --git a/Widgets/NCheckbox.qml b/Widgets/NCheckbox.qml index ee2beaf..962b303 100644 --- a/Widgets/NCheckbox.qml +++ b/Widgets/NCheckbox.qml @@ -11,8 +11,9 @@ RowLayout { property string description: "" property bool checked: false property bool hovering: false - // Smaller default footprint than NToggle - property int baseSize: Math.max(Style.baseWidgetSize * 0.8, Math.round(14 / scaling)) + property color activeColor: Color.mPrimary + property color activeOnColor: Color.mOnPrimary + property int baseSize: Math.max(Style.baseWidgetSize * 0.8, 14) signal toggled(bool checked) signal entered @@ -23,6 +24,7 @@ RowLayout { NLabel { label: root.label description: root.description + visible: root.label !== "" || root.description !== "" } Rectangle { @@ -30,16 +32,16 @@ RowLayout { implicitWidth: root.baseSize * scaling implicitHeight: root.baseSize * scaling - radius: Math.max(2 * scaling, Style.radiusXS * scaling) - color: root.checked ? Color.mPrimary : Color.mSurface - border.color: root.checked ? Color.mPrimary : Color.mOutline + radius: Style.radiusXS * scaling + color: root.checked ? root.activeColor : Color.mSurface + border.color: root.checked ? root.activeColor : Color.mOutline border.width: Math.max(1, Style.borderM * scaling) NIcon { visible: root.checked anchors.centerIn: parent text: "check" - color: Color.mOnPrimary + color: root.activeOnColor font.pointSize: Math.max(Style.fontSizeS, root.baseSize * 0.7) * scaling } diff --git a/Widgets/NTextInput.qml b/Widgets/NTextInput.qml index bc1a955..6533af9 100644 --- a/Widgets/NTextInput.qml +++ b/Widgets/NTextInput.qml @@ -4,74 +4,68 @@ import QtQuick.Layouts import qs.Commons import qs.Services -Item { +ColumnLayout { id: root property string label: "" property string description: "" property bool readOnly: false property bool enabled: true + property int inputMaxWidth: 420 * scaling property alias text: input.text property alias placeholderText: input.placeholderText + property alias inputMethodHints: input.inputMethodHints signal editingFinished - // Sizing - implicitWidth: Style.sliderWidth * 1.6 * scaling - implicitHeight: Style.baseWidgetSize * 2.75 * scaling + spacing: Style.marginS * scaling + implicitHeight: frame.height - ColumnLayout { - spacing: Style.marginXXS * scaling - Layout.fillWidth: true + NLabel { + label: root.label + description: root.description + visible: root.label !== "" || root.description !== "" + } - NLabel { - label: root.label - description: root.description + // Container + Rectangle { + id: frame + implicitWidth: parent.width + implicitHeight: Style.baseWidgetSize * 1.1 * scaling + Layout.minimumWidth: 80 * scaling + Layout.maximumWidth: root.inputMaxWidth + radius: Style.radiusM * scaling + color: Color.mSurface + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + + // Focus ring + Rectangle { + anchors.fill: parent + radius: frame.radius + color: Color.transparent + border.color: input.activeFocus ? Color.mSecondary : Color.transparent + border.width: input.activeFocus ? Math.max(1, Style.borderS * scaling) : 0 } - // Container - Rectangle { - id: frame - Layout.topMargin: Style.marginXS * scaling - implicitWidth: root.width - implicitHeight: Style.baseWidgetSize * 1.35 * scaling - radius: Style.radiusM * scaling - color: Color.mSurface - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS * scaling) + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.marginM * scaling + anchors.rightMargin: Style.marginM * scaling + spacing: Style.marginS * scaling - // Focus ring - Rectangle { - anchors.fill: parent - radius: frame.radius - color: Color.transparent - border.color: input.activeFocus ? Color.mSecondary : Color.transparent - border.width: input.activeFocus ? Math.max(1, Style.borderS * scaling) : 0 - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Style.marginM * scaling - anchors.rightMargin: Style.marginM * scaling - spacing: Style.marginS * scaling - - // Optional leading icon slot in the future - // Item { Layout.preferredWidth: 0 } - TextField { - id: input - Layout.fillWidth: true - echoMode: TextInput.Normal - readOnly: root.readOnly - enabled: root.enabled - color: Color.mOnSurface - placeholderTextColor: Color.mOnSurface - background: null - font.pointSize: Style.fontSizeXS * scaling - onEditingFinished: root.editingFinished() - // Text changes are observable via the aliased 'text' property (root.text) and its 'textChanged' signal. - // No additional callback is invoked here to avoid conflicts with QML's onTextChanged handler semantics. - } + TextField { + id: input + Layout.fillWidth: true + echoMode: TextInput.Normal + readOnly: root.readOnly + enabled: root.enabled + color: Color.mOnSurface + placeholderTextColor: Color.mOnSurfaceVariant + background: null + font.pointSize: Style.fontSizeS * scaling + onEditingFinished: root.editingFinished() } } } diff --git a/shell.qml b/shell.qml index c62e8a2..0d85497 100644 --- a/shell.qml +++ b/shell.qml @@ -21,7 +21,6 @@ import qs.Modules.Calendar import qs.Modules.Dock import qs.Modules.IPC import qs.Modules.LockScreen -import qs.Modules.NightLight import qs.Modules.Notification import qs.Modules.SettingsPanel import qs.Modules.PowerPanel @@ -51,12 +50,10 @@ ShellRoot { ToastOverlay {} - NightLightOverlay {} - IPCManager {} // ------------------------------ - // All the panels + // All the NPanels Launcher { id: launcherPanel objectName: "launcherPanel"