nosignal-shell/modules/nsbar/panels/NsQuickSettings.qml
28allday 44b257ce85 feat(nosignal): CachyOS repo toggle + wire Quick Settings placebos
- CachyRepoToggleRow.qml (Settings -> Services): sudoless CachyOS optimized
  repos + linux-cachyos kernel switch, auto-tier (v4>v3); polls
  nosignal-cachy-repo status, runs enable/disable in a floating kitty. Inserted
  after SudoToggleRow in ServicesPage.qml.
- NsQuickSettings.qml: Night Light now drives hyprsunset (tile bound to the real
  pgrep'd process, not a flag); Airplane drives nmcli + bluetoothctl and binds to
  real radio state; VPN placebo tile removed (no backend).

Both built + live-tested on the reference box (06-17 PM). Helper script, sudoers
and hyprsunset dep ship in the nosignal builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:06:20 +01:00

424 lines
13 KiB
QML

pragma ComponentBehavior: Bound
// NoSignal Quick Settings popout (off the media / battery pill). Avatar + user +
// uptime + power button; 2-col toggle grid; brightness + volume sliders; media
// card. Wired to the real services (Network, Bluetooth, Notifs, Audio,
// Brightness, Players). Night Light drives hyprsunset; Airplane drives the
// NetworkManager + Bluetooth radios. (VPN tile removed pending a real backend.)
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Bluetooth
import qs.services
import qs.utils
import qs.components
NsPanel {
id: root
implicitWidth: 380
anchorMode: "right"
readonly property var brightMon: Brightness.monitors[0] ?? null
// Night Light state tracks the real hyprsunset process rather than a local
// flag, so the tile stays correct across shell reloads or changes made
// elsewhere. Seeded on load (probe runs immediately), reconciled on a timer,
// and re-checked right after a toggle.
property bool nightLightOn: false
Process {
id: nightProbe
command: ["pgrep", "-x", "hyprsunset"]
running: true
stdout: StdioCollector {
onStreamFinished: root.nightLightOn = text.trim().length > 0
}
}
Timer {
interval: 3000
running: true
repeat: true
onTriggered: nightProbe.running = true
}
Timer {
id: nightRefresh
interval: 500
repeat: false
onTriggered: nightProbe.running = true
}
// ── Header ───────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 12
Rectangle {
implicitWidth: 40
implicitHeight: 40
radius: 0
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop {
position: 0
color: Theme.accent
}
GradientStop {
position: 1
color: Theme.blue
}
}
StyledText {
anchors.centerIn: parent
text: (SysInfo.user || "?").charAt(0).toUpperCase()
color: Theme.onAccent
font.family: Theme.font.family
font.pixelSize: Theme.font.body
font.weight: Font.Bold
}
}
ColumnLayout {
spacing: 0
StyledText {
text: SysInfo.user || "user"
color: Theme.text
font.family: Theme.font.family
font.pixelSize: Theme.font.panelTitle
font.weight: Font.DemiBold
}
StyledText {
text: "uptime " + SysInfo.uptime
color: Theme.textMuted
font.family: Theme.font.family
font.pixelSize: Theme.font.bodySmall
}
}
Item {
Layout.fillWidth: true
}
Rectangle {
implicitWidth: 34
implicitHeight: 34
radius: 0
color: pwrMa.containsMouse ? Theme.accentSoft : Theme.fillSubtle
NsIcon {
anchors.centerIn: parent
icon: "power_settings_new"
color: Theme.danger
}
MouseArea {
id: pwrMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: NsShell.show("power", 0)
}
}
}
// ── Toggle grid ──────────────────────────────────────────────────────
GridLayout {
Layout.fillWidth: true
columns: 2
rowSpacing: 8
columnSpacing: 8
QSToggle {
icon: "wifi"
label: "Wi-Fi"
sub: Network.wifiEnabled ? Network.active?.ssid ?? "On" : "Off"
on: Network.wifiEnabled
onToggled: Network.toggleWifi()
}
QSToggle {
icon: "bluetooth"
label: "Bluetooth"
sub: Bluetooth.defaultAdapter?.enabled ? "On" : "Off"
on: Bluetooth.defaultAdapter?.enabled ?? false
onToggled: if (Bluetooth.defaultAdapter)
Bluetooth.defaultAdapter.enabled = !Bluetooth.defaultAdapter.enabled
}
QSToggle {
icon: "do_not_disturb_on"
label: "Do Not Disturb"
sub: Notifs.dnd ? "On" : "Off"
on: Notifs.dnd
onToggled: Notifs.dnd = !Notifs.dnd
}
QSToggle {
id: nightTog
icon: "nightlight"
label: "Night Light"
sub: on ? "Warm 3500K" : "Off"
on: root.nightLightOn
onToggled: {
if (root.nightLightOn)
Quickshell.execDetached(["pkill", "-x", "hyprsunset"]);
else
Quickshell.execDetached(["sh", "-c", "pkill -x hyprsunset; hyprsunset -t 3500"]);
nightRefresh.restart();
}
}
QSToggle {
id: airTog
icon: "airplanemode_active"
label: "Airplane"
// Reflects the real radio state: airplane is "On" when both Wi-Fi and
// Bluetooth are down. Network.wifiEnabled tracks nmcli; the adapter
// tracks BlueZ, so the tile self-corrects if radios change elsewhere.
readonly property bool radiosOff: !Network.wifiEnabled && !(Bluetooth.defaultAdapter?.enabled ?? false)
sub: radiosOff ? "On" : "Off"
on: radiosOff
onToggled: {
if (radiosOff)
Quickshell.execDetached(["sh", "-c", "nmcli radio all on; bluetoothctl power on"]);
else
Quickshell.execDetached(["sh", "-c", "nmcli radio all off; bluetoothctl power off"]);
}
}
}
// ── Sliders ──────────────────────────────────────────────────────────
QSSlider {
icon: "brightness_6"
value: root.brightMon?.brightness ?? 0
visible: root.brightMon !== null
onMoved: v => root.brightMon?.setBrightness(v)
}
QSSlider {
icon: Audio.muted ? "volume_off" : "volume_up"
value: Audio.volume
onMoved: v => Audio.setVolume(v)
}
// ── Media card ───────────────────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.topMargin: 4
implicitHeight: 80
radius: Theme.radius.tile
color: Theme.fillSubtle
visible: Players.active !== null
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 12
Rectangle {
implicitWidth: 56
implicitHeight: 56
radius: 0
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop {
position: 0
color: Theme.accent
}
GradientStop {
position: 1
color: Theme.blue
}
}
NsIcon {
anchors.centerIn: parent
icon: "music_note"
color: Theme.onAccent
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
StyledText {
Layout.fillWidth: true
text: Players.active?.trackTitle || "Awake"
color: Theme.text
elide: Text.ElideRight
font.family: Theme.font.family
font.pixelSize: Theme.font.body
font.weight: Font.DemiBold
}
StyledText {
Layout.fillWidth: true
text: Players.active?.trackArtist || ""
color: Theme.textMuted
elide: Text.ElideRight
font.family: Theme.font.family
font.pixelSize: Theme.font.bodySmall
}
RowLayout {
spacing: 16
Layout.topMargin: 2
MediaBtn {
icon: "skip_previous"
onActivated: Players.previous()
}
MediaBtn {
icon: Players.active?.isPlaying ? "pause" : "play_arrow"
onActivated: Players.togglePlaying()
}
MediaBtn {
icon: "skip_next"
onActivated: Players.next()
}
}
}
}
}
// ── Reusable bits ────────────────────────────────────────────────────
component QSToggle: Rectangle {
id: tile
property string icon
property string label
property string sub
property bool on: false
signal toggled
Layout.fillWidth: true
implicitHeight: 56
radius: Theme.radius.button
color: tile.on ? Theme.accentSoft : Theme.fillSubtle
border.width: tile.on ? 1 : 0
border.color: Theme.accent
RowLayout {
anchors.fill: parent
anchors.margins: 11
spacing: 10
Rectangle {
implicitWidth: 30
implicitHeight: 30
radius: 0
color: tile.on ? Theme.accent : Qt.rgba(1, 1, 1, 0.06)
NsIcon {
anchors.centerIn: parent
icon: tile.icon
color: tile.on ? Theme.onAccent : Theme.text
}
}
ColumnLayout {
spacing: 0
StyledText {
text: tile.label
color: Theme.text
font.family: Theme.font.family
font.pixelSize: Theme.font.bodySmall
font.weight: Font.Medium
}
StyledText {
text: tile.sub
color: Theme.textMuted
font.family: Theme.font.family
font.pixelSize: Theme.font.meta
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: tile.toggled()
}
}
component QSSlider: RowLayout {
id: s
property string icon
property real value: 0
signal moved(real v)
Layout.fillWidth: true
spacing: 10
NsIcon {
icon: s.icon
color: Theme.textMuted
}
Rectangle {
id: track
Layout.fillWidth: true
implicitHeight: 6
radius: 0
color: Theme.fillSubtle
Rectangle {
width: parent.width * Math.max(0, Math.min(1, s.value))
height: parent.height
radius: 0
color: Theme.accent
}
Rectangle {
x: parent.width * Math.max(0, Math.min(1, s.value)) - width / 2
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 14
implicitHeight: 14
radius: 0
color: Theme.accent
}
MouseArea {
anchors.fill: parent
onPressed: e => s.moved(Math.max(0, Math.min(1, e.x / width)))
onPositionChanged: e => {
if (pressed)
s.moved(Math.max(0, Math.min(1, e.x / width)));
}
}
}
StyledText {
text: Math.round(s.value * 100) + "%"
color: Theme.textMuted
font.family: Theme.font.family
font.pixelSize: Theme.font.meta
}
}
component MediaBtn: Rectangle {
property string icon
signal activated
implicitWidth: 28
implicitHeight: 28
radius: 0
color: mbm.containsMouse ? Theme.hover : "transparent"
NsIcon {
anchors.centerIn: parent
icon: parent.icon
color: Theme.text
}
MouseArea {
id: mbm
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.activated()
}
}
}