Merge branch 'noctalia-dev:main' into main

This commit is contained in:
Michael Thomas 2025-08-28 16:45:50 -04:00 committed by GitHub
commit 85bd0ed2f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 929 additions and 650 deletions

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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() {

View file

@ -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
}
}
}
}
}
}

View file

@ -301,6 +301,7 @@ NPanel {
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
padding: Style.marginL * scaling
clip: true
Loader {
active: true

View file

@ -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 +"

View file

@ -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)"

View file

@ -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
}
}

View file

@ -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
}

View file

@ -24,7 +24,8 @@ ColumnLayout {
onEditingFinished: {
Settings.data.screenRecorder.directory = text
}
Layout.fillWidth: true
Layout.maximumWidth: 420 * scaling
}
ColumnLayout {

View file

@ -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 {

View file

@ -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) {

View file

@ -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` |

View file

@ -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)

View file

@ -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
}

View file

@ -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")

View file

@ -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
}
}
}
}

View file

@ -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
}

View file

@ -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()
}
}
}

View file

@ -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"