diff --git a/shell/applets/BacklightApplet.qml b/shell/applets/BacklightApplet.qml new file mode 100644 index 0000000..c9a60f5 --- /dev/null +++ b/shell/applets/BacklightApplet.qml @@ -0,0 +1,79 @@ +import QtQuick +import "../services" as S + +Item { + id: root + + required property int percent + required property color accentColor + + signal setPercent(real pct) + + implicitHeight: 36 + + Text { + id: _icon + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "\uF185" + color: root.accentColor + font.pixelSize: S.Theme.fontSize + 2 + font.family: S.Theme.iconFontFamily + } + + Item { + id: _slider + anchors.left: _icon.right + anchors.leftMargin: 8 + anchors.right: _label.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + height: 6 + + Rectangle { + anchors.fill: parent + color: S.Theme.base02 + radius: 3 + } + + Rectangle { + width: parent.width * root.percent / 100 + height: parent.height + color: root.accentColor + radius: 3 + + Behavior on width { + NumberAnimation { + duration: 80 + } + } + } + + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onPressed: mouse => _set(mouse) + onPositionChanged: mouse => { + if (pressed) + _set(mouse); + } + function _set(mouse) { + root.setPercent(mouse.x / _slider.width * 100); + } + } + } + + Text { + id: _label + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: root.percent + "%" + color: S.Theme.base05 + font.pixelSize: S.Theme.fontSize + font.family: S.Theme.fontFamily + width: 30 + } +} diff --git a/shell/applets/qmldir b/shell/applets/qmldir index 78bc931..8a5282f 100644 --- a/shell/applets/qmldir +++ b/shell/applets/qmldir @@ -6,3 +6,4 @@ CpuApplet 1.0 CpuApplet.qml MemoryApplet 1.0 MemoryApplet.qml TemperatureApplet 1.0 TemperatureApplet.qml DiskApplet 1.0 DiskApplet.qml +BacklightApplet 1.0 BacklightApplet.qml diff --git a/shell/lock/LockSurface.qml b/shell/lock/LockSurface.qml index 757c6ee..d79d576 100644 --- a/shell/lock/LockSurface.qml +++ b/shell/lock/LockSurface.qml @@ -115,16 +115,6 @@ WlSessionLockSurface { anchors.horizontalCenter: parent.horizontalCenter spacing: 24 - LockNotifPills { - anchors.horizontalCenter: parent.horizontalCenter - } - - // Spacer - Item { - width: 1 - height: 24 - } - // Password input LockInput { anchors.horizontalCenter: parent.horizontalCenter diff --git a/shell/lock/LockWidgets.qml b/shell/lock/LockWidgets.qml index 1818cad..1d51ac3 100644 --- a/shell/lock/LockWidgets.qml +++ b/shell/lock/LockWidgets.qml @@ -1,5 +1,4 @@ import QtQuick -import Quickshell.Services.Mpris import Quickshell.Services.Pipewire import "../services" as S import "../applets" as C @@ -28,13 +27,19 @@ Item { } implicitHeight: _widgetContent.implicitHeight - visible: _mprisCard.visible || _volumeCard.visible + visible: _mprisCard.visible || _volumeCard.visible || _backlightCard.visible || _notifPills.visible Column { id: _widgetContent width: parent.width spacing: 12 + // Notification pills + LockNotifPills { + id: _notifPills + anchors.horizontalCenter: parent.horizontalCenter + } + // Media widget Rectangle { id: _mprisCard @@ -44,12 +49,7 @@ Item { color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7) border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3) border.width: 1 - visible: (S.Modules.lock.mpris ?? true) && _mprisPlayer !== null - - readonly property var _mprisPlayers: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused) - property int _playerIdx: 0 - readonly property var _mprisPlayer: _mprisPlayers[_playerIdx] ?? _mprisPlayers[0] ?? null - readonly property bool _playing: _mprisPlayer?.playbackState === MprisPlaybackState.Playing + visible: (S.Modules.lock.mpris ?? true) && S.MprisService.player !== null C.MprisApplet { id: _mprisContent @@ -57,15 +57,13 @@ Item { anchors.right: parent.right anchors.top: parent.top anchors.topMargin: 8 - player: _mprisCard._mprisPlayer - players: _mprisCard._mprisPlayers - playing: _mprisCard._playing - playerIdx: _mprisCard._playerIdx + player: S.MprisService.player + players: S.MprisService.players + playing: S.MprisService.playing + playerIdx: S.MprisService.playerIdx accentColor: S.Theme.base0D - cachedArt: _mprisCard._mprisPlayer?.trackArtUrl ?? "" - onPlayerSwitched: idx => { - _mprisCard._playerIdx = idx; - } + cachedArt: S.MprisService.player?.trackArtUrl ?? "" + onPlayerSwitched: idx => S.MprisService.switchPlayer(idx) } } @@ -96,5 +94,28 @@ Item { accentColor: S.Theme.base0E } } + + // Brightness widget + Rectangle { + id: _backlightCard + width: parent.width + height: _backlightContent.implicitHeight + 8 + radius: S.Theme.radius + 2 + color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7) + border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3) + border.width: 1 + visible: S.BacklightService.available + + C.BacklightApplet { + id: _backlightContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 4 + percent: S.BacklightService.percent + accentColor: S.Theme.base0A + onSetPercent: pct => S.BacklightService.setPercent(pct) + } + } } } diff --git a/shell/modules/BacklightModule.qml b/shell/modules/BacklightModule.qml index 3ca36dd..67ab22f 100644 --- a/shell/modules/BacklightModule.qml +++ b/shell/modules/BacklightModule.qml @@ -1,17 +1,17 @@ import QtQuick import Quickshell -import Quickshell.Io import "." as M import "../services" as S +import "../applets" as C M.BarSection { id: root spacing: S.Theme.moduleSpacing - opacity: S.Modules.backlight.enable && percent > 0 ? 1 : 0 + opacity: S.Modules.backlight.enable && S.BacklightService.available ? 1 : 0 visible: opacity > 0 tooltip: "" - property int percent: 0 + property int percent: S.BacklightService.percent property bool _osdActive: false property bool _percentInit: false readonly property bool _showPanel: root._hovered || hoverPanel.panelHovered || _osdActive @@ -36,71 +36,8 @@ M.BarSection { onTriggered: root._osdActive = false } - Process { - id: adjProc - property string cmd: "" - command: ["sh", "-c", cmd] - onRunningChanged: if (!running && cmd !== "") - current.reload() - } - - function adjust(delta) { - const step = S.Modules.backlight.step || 5; - adjProc.cmd = delta > 0 ? "light -A " + step : "light -U " + step; - adjProc.running = true; - } - - function setPercent(pct) { - adjProc.cmd = "light -S " + Math.round(Math.max(0, Math.min(100, pct))); - adjProc.running = true; - } - - property string _blDev: "" - Process { - id: detectBl - running: true - command: ["sh", "-c", "ls /sys/class/backlight/ 2>/dev/null | head -1"] - stdout: StdioCollector { - onStreamFinished: { - const dev = text.trim(); - if (dev) - root._blDev = "/sys/class/backlight/" + dev; - } - } - } - - FileView { - id: current - path: root._blDev ? root._blDev + "/brightness" : "" - watchChanges: true - onFileChanged: reload() - onLoaded: root._update() - } - FileView { - id: max - path: root._blDev ? root._blDev + "/max_brightness" : "" - onLoaded: root._update() - } - - function _update() { - const c = parseInt(current.text()); - const m = parseInt(max.text()); - if (m > 0) - root.percent = Math.round((c / m) * 100); - } - - M.BarIcon { - icon: "\uF185" - anchors.verticalCenter: parent.verticalCenter - } - M.BarLabel { - label: root.percent + "%" - minText: "100%" - anchors.verticalCenter: parent.verticalCenter - } - WheelHandler { - onWheel: event => root.adjust(event.angleDelta.y) + onWheel: event => S.BacklightService.adjust(event.angleDelta.y) } M.HoverPanel { @@ -113,75 +50,21 @@ M.BarSection { panelTitle: "Brightness" contentWidth: 200 - Item { + C.BacklightApplet { width: parent.width - height: 36 - - Text { - id: blIcon - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: "\uF185" - color: root.accentColor - font.pixelSize: S.Theme.fontSize + 2 - font.family: S.Theme.iconFontFamily - } - - Item { - id: slider - anchors.left: blIcon.right - anchors.leftMargin: 8 - anchors.right: blLabel.left - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - height: 6 - - Rectangle { - anchors.fill: parent - color: S.Theme.base02 - radius: 3 - } - - Rectangle { - width: parent.width * root.percent / 100 - height: parent.height - color: root.accentColor - radius: 3 - - Behavior on width { - NumberAnimation { - duration: 80 - } - } - } - - MouseArea { - anchors.fill: parent - anchors.margins: -6 - cursorShape: Qt.PointingHandCursor - onPressed: mouse => _set(mouse) - onPositionChanged: mouse => { - if (pressed) - _set(mouse); - } - function _set(mouse) { - root.setPercent(mouse.x / slider.width * 100); - } - } - } - - Text { - id: blLabel - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: root.percent + "%" - color: S.Theme.base05 - font.pixelSize: S.Theme.fontSize - font.family: S.Theme.fontFamily - width: 30 - } + percent: root.percent + accentColor: root.accentColor + onSetPercent: pct => S.BacklightService.setPercent(pct) } } + + M.BarIcon { + icon: "\uF185" + anchors.verticalCenter: parent.verticalCenter + } + M.BarLabel { + label: root.percent + "%" + minText: "100%" + anchors.verticalCenter: parent.verticalCenter + } } diff --git a/shell/modules/BluetoothMenu.qml b/shell/modules/BluetoothMenu.qml index 67a2478..f348e90 100644 --- a/shell/modules/BluetoothMenu.qml +++ b/shell/modules/BluetoothMenu.qml @@ -1,6 +1,5 @@ import QtQuick import Quickshell -import Quickshell.Io import "." as M import "../services" as S @@ -19,7 +18,7 @@ M.HoverPanel { Text { anchors.centerIn: parent text: "\uF011" - color: menuWindow._btEnabled ? menuWindow.accentColor : S.Theme.base04 + color: S.BluetoothService.enabled ? menuWindow.accentColor : S.Theme.base04 font.pixelSize: S.Theme.fontSize font.family: S.Theme.iconFontFamily @@ -35,78 +34,16 @@ M.HoverPanel { } TapHandler { - onTapped: { - powerProc._action = menuWindow._btEnabled ? "off" : "on"; - powerProc.running = true; - } + onTapped: S.BluetoothService.setPower(!S.BluetoothService.enabled) } } } onVisibleChanged: if (visible) - scanner.running = true - - property var _devices: [] - property bool _btEnabled: true - - property Process _scanner: Process { - id: scanner - running: false - command: ["sh", "-c", "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2; exit}';" + "echo '---DEVICES---';" + "bluetoothctl devices Paired 2>/dev/null | while read -r _ mac name; do " + "info=$(bluetoothctl info \"$mac\" 2>/dev/null); " + "conn=$(echo \"$info\" | grep -c 'Connected: yes'); " + "bat=$(echo \"$info\" | awk -F'[(): ]' '/Battery Percentage/{for(i=1;i<=NF;i++) if($i+0==$i && $i!=\"\") print $i}'); " + "echo \"$mac:$conn:${bat:-}:$name\"; " + "done"] - stdout: StdioCollector { - onStreamFinished: { - const sections = text.split("---DEVICES---"); - menuWindow._btEnabled = (sections[0] || "").trim() === "yes"; - - const devs = []; - for (const line of (sections[1] || "").trim().split("\n")) { - if (!line) - continue; - const i1 = line.indexOf(":"); - const i2 = line.indexOf(":", i1 + 1); - const i3 = line.indexOf(":", i2 + 1); - if (i3 < 0) - continue; - devs.push({ - "mac": line.slice(0, i1), - "connected": line.slice(i1 + 1, i2) === "1", - "battery": parseInt(line.slice(i2 + 1, i3)) || -1, - "name": line.slice(i3 + 1) - }); - } - devs.sort((a, b) => { - if (a.connected !== b.connected) - return a.connected ? -1 : 1; - return a.name.localeCompare(b.name); - }); - menuWindow._devices = devs; - } - } - } - - property Process _powerProc: Process { - id: powerProc - property string _action: "" - command: ["bluetoothctl", "power", _action] - onRunningChanged: if (!running) { - scanner.running = true; - menuWindow.keepOpen(500); - } - } - - property Process _toggleProc: Process { - id: toggleProc - property string action: "" - property string mac: "" - command: ["bluetoothctl", action, mac] - onRunningChanged: if (!running) { - scanner.running = true; - menuWindow.keepOpen(500); - } - } + S.BluetoothService.refresh() Repeater { - model: menuWindow._devices + model: S.BluetoothService.devices delegate: Item { id: entry @@ -167,21 +104,20 @@ M.HoverPanel { } TapHandler { onTapped: { - toggleProc.action = entry.modelData.connected ? "disconnect" : "connect"; - toggleProc.mac = entry.modelData.mac; - toggleProc.running = true; + S.BluetoothService.toggleDevice(entry.modelData.mac, !entry.modelData.connected); + menuWindow.keepOpen(500); } } } } Text { - visible: menuWindow._devices.length === 0 + visible: S.BluetoothService.devices.length === 0 width: menuWindow.contentWidth height: 32 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter - text: menuWindow._btEnabled ? "No paired devices" : "Bluetooth is off" + text: S.BluetoothService.enabled ? "No paired devices" : "Bluetooth is off" color: S.Theme.base04 font.pixelSize: S.Theme.fontSize font.family: S.Theme.fontFamily diff --git a/shell/modules/BluetoothModule.qml b/shell/modules/BluetoothModule.qml index 03ecf5c..81e715e 100644 --- a/shell/modules/BluetoothModule.qml +++ b/shell/modules/BluetoothModule.qml @@ -1,72 +1,24 @@ import QtQuick import Quickshell -import Quickshell.Io import "." as M import "../services" as S M.BarSection { id: root spacing: S.Theme.moduleSpacing - opacity: S.Modules.bluetooth.enable && root.state !== "unavailable" ? 1 : 0 + opacity: S.Modules.bluetooth.enable && S.BluetoothService.state !== "unavailable" ? 1 : 0 visible: opacity > 0 tooltip: { - if (root.state === "off") + if (S.BluetoothService.state === "off") return "Bluetooth: off"; - if (root.state === "connected") - return "Bluetooth: " + root.device + (root.batteryPct >= 0 ? "\nBattery: " + root.batteryPct + "%" : ""); + if (S.BluetoothService.state === "connected") + return "Bluetooth: " + S.BluetoothService.device + (S.BluetoothService.batteryPct >= 0 ? "\nBattery: " + S.BluetoothService.batteryPct + "%" : ""); return "Bluetooth: on"; } - property string state: "unavailable" - property string device: "" - property int batteryPct: -1 - - function _parse(text) { - const lines = text.trim().split("\n"); - const t = lines[0] || ""; - const sep = t.indexOf(":"); - root.state = sep === -1 ? t : t.slice(0, sep); - root.device = sep === -1 ? "" : t.slice(sep + 1); - root.batteryPct = -1; - for (let i = 1; i < lines.length; i++) { - if (lines[i].startsWith("bat:")) - root.batteryPct = parseInt(lines[i].slice(4)) || -1; - } - } - - Process { - id: proc - running: S.Modules.bluetooth.enable - command: ["sh", "-c", "s=$(bluetoothctl show 2>/dev/null); " + "[ -z \"$s\" ] && echo unavailable && exit; " + "echo \"$s\" | grep -q 'Powered: yes' || { echo off:; exit; }; " + "info=$(bluetoothctl info 2>/dev/null); " + "d=$(echo \"$info\" | awk -F': ' '/\\tName:/{n=$2}/Connected: yes/{c=1}END{if(c)print n}'); " + "[ -n \"$d\" ] && echo \"connected:$d\" || { echo on:; exit; }; " + "bat=$(echo \"$info\" | awk -F': ' '/Battery Percentage.*\\(/{gsub(/[^0-9]/,\"\",$2);print $2}'); " + "[ -n \"$bat\" ] && echo \"bat:$bat\""] - stdout: StdioCollector { - onStreamFinished: root._parse(text) - } - } - // Event-driven: watch BlueZ DBus property changes - Process { - id: btMonitor - running: S.Modules.bluetooth.enable - command: ["sh", "-c", "dbus-monitor --system \"interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'\" 2>/dev/null"] - stdout: SplitParser { - splitMarker: "\n" - onRead: _debounce.restart() - } - } - Timer { - id: _debounce - interval: 500 - onTriggered: proc.running = true - } - Timer { - interval: 60000 - running: S.Modules.bluetooth.enable - repeat: true - onTriggered: proc.running = true - } - M.BarIcon { icon: "\uF294" - color: root.state === "off" ? S.Theme.base04 : root.accentColor + color: S.BluetoothService.state === "off" ? S.Theme.base04 : root.accentColor anchors.verticalCenter: parent.verticalCenter TapHandler { onTapped: { @@ -76,8 +28,8 @@ M.BarSection { } } M.BarLabel { - visible: root.state === "connected" - label: root.device + (root.batteryPct >= 0 ? " " + root.batteryPct + "%" : "") + visible: S.BluetoothService.state === "connected" + label: S.BluetoothService.device + (S.BluetoothService.batteryPct >= 0 ? " " + S.BluetoothService.batteryPct + "%" : "") anchors.verticalCenter: parent.verticalCenter TapHandler { onTapped: { diff --git a/shell/modules/IdleInhibitorModule.qml b/shell/modules/IdleInhibitorModule.qml index 81508a9..4cda17d 100644 --- a/shell/modules/IdleInhibitorModule.qml +++ b/shell/modules/IdleInhibitorModule.qml @@ -1,51 +1,31 @@ import QtQuick import Quickshell -import Quickshell.Io import "." as M import "../services" as S M.BarIcon { id: root - color: root.active ? S.Theme.base09 : root.accentColor + color: S.IdleInhibitService.active ? S.Theme.base09 : root.accentColor tooltip: { - const parts = ["Idle inhibition: " + (root.active ? "active" : "inactive")]; - if (root._inhibitors) - parts.push(root._inhibitors); + const parts = ["Idle inhibition: " + (S.IdleInhibitService.active ? "active" : "inactive")]; + if (S.IdleInhibitService.inhibitors) + parts.push(S.IdleInhibitService.inhibitors); return parts.join("\n"); } - property bool active: false - property string _inhibitors: "" - - icon: root.active ? "\uF06E" : "\uF070" - - Process { - id: inhibitor - command: ["systemd-inhibit", "--what=idle", "--who=nova-shell", "--why=user", "sleep", "infinity"] - running: root.active - } - - // Poll current inhibitors - Process { - id: listProc - running: true - command: ["sh", "-c", "systemd-inhibit --list 2>/dev/null | grep -i idle | awk '{print $NF}' | sort -u | tr '\\n' ', ' | sed 's/, $//'"] - stdout: StdioCollector { - onStreamFinished: root._inhibitors = text.trim() ? "Blocked by: " + text.trim() : "" - } - } + icon: S.IdleInhibitService.active ? "\uF06E" : "\uF070" Timer { interval: 5000 running: root._hovered repeat: true triggeredOnStart: true - onTriggered: listProc.running = true + onTriggered: S.IdleInhibitService.refreshInhibitors() } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: root.active = !root.active + onClicked: S.IdleInhibitService.toggle() } } diff --git a/shell/modules/MprisModule.qml b/shell/modules/MprisModule.qml index 2072bce..0edbf0e 100644 --- a/shell/modules/MprisModule.qml +++ b/shell/modules/MprisModule.qml @@ -13,14 +13,9 @@ M.BarSection { visible: opacity > 0 tooltip: "" - property int _playerIdx: 0 - readonly property var _players: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused) - readonly property MprisPlayer player: _players[_playerIdx] ?? _players[0] ?? null - readonly property bool playing: player?.playbackState === MprisPlaybackState.Playing - - // Reset index if current player disappears - on_PlayersChanged: if (_playerIdx >= _players.length) - _playerIdx = 0 + readonly property var _players: S.MprisService.players + readonly property MprisPlayer player: S.MprisService.player + readonly property bool playing: S.MprisService.playing property string _cachedArt: "" property string _artTrack: "" @@ -132,9 +127,9 @@ M.BarSection { accentColor: root.accentColor cachedArt: root._cachedArt cavaBars: root._cavaBars - playerIdx: root._playerIdx + playerIdx: S.MprisService.playerIdx onPlayerSwitched: idx => { - root._playerIdx = idx; + S.MprisService.switchPlayer(idx); hoverPanel.keepOpen(400); } } diff --git a/shell/modules/NetworkMenu.qml b/shell/modules/NetworkMenu.qml index 36e41dd..f9c656f 100644 --- a/shell/modules/NetworkMenu.qml +++ b/shell/modules/NetworkMenu.qml @@ -1,6 +1,5 @@ import QtQuick import Quickshell -import Quickshell.Io import "." as M import "../services" as S @@ -18,7 +17,7 @@ M.HoverPanel { Text { anchors.centerIn: parent text: "\uF011" - color: menuWindow._wifiEnabled ? menuWindow.accentColor : S.Theme.base04 + color: S.NetworkService.wifiEnabled ? menuWindow.accentColor : S.Theme.base04 font.pixelSize: S.Theme.fontSize font.family: S.Theme.iconFontFamily @@ -34,116 +33,16 @@ M.HoverPanel { } TapHandler { - onTapped: { - radioProc._state = menuWindow._wifiEnabled ? "off" : "on"; - radioProc.running = true; - } + onTapped: S.NetworkService.setWifi(!S.NetworkService.wifiEnabled) } } } onVisibleChanged: if (visible) - scanner.running = true - - function triggerRefresh() { - if (visible) - scanner.running = true; - } - - property var _networks: [] - property bool _wifiEnabled: true - - property Process _scanner: Process { - id: scanner - running: true - command: ["sh", "-c", "echo '---RADIO---';" + "nmcli radio wifi 2>/dev/null;" + "echo '---CONNS---';" + "nmcli -t -f NAME,UUID,TYPE,ACTIVE connection show 2>/dev/null;" + "echo '---WIFI---';" + "nmcli -t -f SSID,SIGNAL device wifi list --rescan no 2>/dev/null"] - stdout: StdioCollector { - onStreamFinished: { - const radioSection = text.split("---CONNS---")[0].split("---RADIO---")[1] || ""; - menuWindow._wifiEnabled = radioSection.trim() === "enabled"; - - const sections = text.split("---WIFI---"); - const connLines = (sections[0] || "").split("---CONNS---")[1] || ""; - const wifiLines = sections[1] || ""; - - const visible = {}; - for (const l of wifiLines.trim().split("\n")) { - if (!l) - continue; - const parts = l.split(":"); - const ssid = parts[0]; - if (ssid) - visible[ssid] = parseInt(parts[1]) || 0; - } - - const nets = []; - for (const l of connLines.trim().split("\n")) { - if (!l) - continue; - const parts = l.split(":"); - const name = parts[0]; - const uuid = parts[1]; - const type = parts[2] || ""; - const active = parts[3] === "yes"; - const isWifi = type.includes("wireless"); - - if (isWifi && !(name in visible)) - continue; - - nets.push({ - "name": name, - "uuid": uuid, - "isWifi": isWifi, - "active": active, - "signal": isWifi ? (visible[name] || 0) : -1 - }); - } - - nets.sort((a, b) => { - if (a.active !== b.active) - return a.active ? -1 : 1; - if (a.signal >= 0 && b.signal >= 0) - return b.signal - a.signal; - return a.name.localeCompare(b.name); - }); - - menuWindow._networks = nets; - } - } - } - - property Process _radioProc: Process { - id: radioProc - property string _state: "" - command: ["nmcli", "radio", "wifi", _state] - onRunningChanged: if (!running) { - scanner.running = true; - menuWindow.keepOpen(500); - } - } - - property Process _connectProc: Process { - id: connectProc - property string uuid: "" - command: ["nmcli", "connection", "up", uuid] - onRunningChanged: if (!running) { - scanner.running = true; - menuWindow.keepOpen(500); - } - } - - property Process _disconnectProc: Process { - id: disconnectProc - property string uuid: "" - command: ["nmcli", "connection", "down", uuid] - onRunningChanged: if (!running) { - scanner.running = true; - menuWindow.keepOpen(500); - } - } + S.NetworkService.refresh() Repeater { - model: menuWindow._networks + model: S.NetworkService.networks delegate: Item { id: entry @@ -204,25 +103,23 @@ M.HoverPanel { } TapHandler { onTapped: { - if (entry.modelData.active) { - disconnectProc.uuid = entry.modelData.uuid; - disconnectProc.running = true; - } else { - connectProc.uuid = entry.modelData.uuid; - connectProc.running = true; - } + if (entry.modelData.active) + S.NetworkService.disconnectNetwork(entry.modelData.uuid); + else + S.NetworkService.connectNetwork(entry.modelData.uuid); + menuWindow.keepOpen(500); } } } } Text { - visible: menuWindow._networks.length === 0 + visible: S.NetworkService.networks.length === 0 width: menuWindow.contentWidth height: 32 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter - text: menuWindow._wifiEnabled ? "No networks available" : "Wi-Fi is off" + text: S.NetworkService.wifiEnabled ? "No networks available" : "Wi-Fi is off" color: S.Theme.base04 font.pixelSize: S.Theme.fontSize font.family: S.Theme.fontFamily diff --git a/shell/modules/NetworkModule.qml b/shell/modules/NetworkModule.qml index 0de5532..d37eff8 100644 --- a/shell/modules/NetworkModule.qml +++ b/shell/modules/NetworkModule.qml @@ -1,6 +1,5 @@ import QtQuick import Quickshell -import Quickshell.Io import "." as M import "../services" as S @@ -9,73 +8,7 @@ M.BarSection { spacing: S.Theme.moduleSpacing tooltip: "" - property string ifname: "" - property string essid: "" - property string state: "disconnected" - property string ipAddr: "" - property string signal: "" - - Process { - id: proc - running: S.Modules.network.enable - command: ["sh", "-c", "line=$(nmcli -t -f NAME,TYPE,DEVICE connection show --active 2>/dev/null | head -1); if [ -z \"$line\" ]; then dev=$(nmcli -t -f DEVICE,STATE device 2>/dev/null | grep ':connected' | grep -v ':unmanaged\\|:unavailable\\|:disconnected\\|:connecting' | head -1 | cut -d: -f1); [ -n \"$dev\" ] && line=\"linked:linked:$dev\"; fi; [ -z \"$line\" ] && exit 0; echo \"$line\"; dev=$(echo \"$line\" | cut -d: -f3); ip=$(nmcli -t -f IP4.ADDRESS device show \"$dev\" 2>/dev/null | head -1 | cut -d: -f2); echo \"ip:${ip:-}\"; sig=$(nmcli -t -f GENERAL.SIGNAL device show \"$dev\" 2>/dev/null | head -1 | cut -d: -f2); echo \"sig:${sig:-}\""] - stdout: StdioCollector { - onStreamFinished: { - const lines = text.trim().split("\n"); - if (!lines[0]) { - root.state = "disconnected"; - root.essid = ""; - root.ifname = ""; - root.ipAddr = ""; - root.signal = ""; - return; - } - const parts = lines[0].split(":"); - root.essid = parts[0] || ""; - root.ifname = parts[2] || ""; - if ((parts[1] || "").includes("wireless")) - root.state = "wifi"; - else if (parts[0] === "linked") - root.state = "linked"; - else - root.state = "eth"; - // Parse extra info lines - root.ipAddr = ""; - root.signal = ""; - for (let i = 1; i < lines.length; i++) { - if (lines[i].startsWith("ip:")) - root.ipAddr = lines[i].slice(3); - else if (lines[i].startsWith("sig:")) - root.signal = lines[i].slice(4); - } - } - } - } - // Event-driven: re-poll on any network change - Process { - id: monitor - running: S.Modules.network.enable - command: ["nmcli", "monitor"] - stdout: SplitParser { - splitMarker: "\n" - onRead: _debounce.restart() - } - } - Timer { - id: _debounce - interval: 300 - onTriggered: { - proc.running = true; - networkMenu.triggerRefresh(); - } - } - // Fallback poll - Timer { - interval: 60000 - running: S.Modules.network.enable - repeat: true - onTriggered: proc.running = true - } + readonly property string state: S.NetworkService.state M.BarIcon { icon: { @@ -92,7 +25,7 @@ M.BarSection { } M.BarLabel { visible: root.state === "wifi" - label: root.essid + label: S.NetworkService.essid color: root.state === "disconnected" ? S.Theme.base08 : root.accentColor anchors.verticalCenter: parent.verticalCenter } diff --git a/shell/services/BacklightService.qml b/shell/services/BacklightService.qml new file mode 100644 index 0000000..caa4cd8 --- /dev/null +++ b/shell/services/BacklightService.qml @@ -0,0 +1,64 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import "." as S + +QtObject { + id: root + + property int percent: 0 + readonly property bool available: _blDev !== "" + + function adjust(delta) { + const step = S.Modules.backlight.step || 5; + _adjProc.cmd = delta > 0 ? "light -A " + step : "light -U " + step; + _adjProc.running = true; + } + + function setPercent(pct) { + _adjProc.cmd = "light -S " + Math.round(Math.max(0, Math.min(100, pct))); + _adjProc.running = true; + } + + property string _blDev: "" + + property Process _detectProc: Process { + running: true + command: ["sh", "-c", "ls /sys/class/backlight/ 2>/dev/null | head -1"] + stdout: StdioCollector { + onStreamFinished: { + const dev = text.trim(); + if (dev) + root._blDev = "/sys/class/backlight/" + dev; + } + } + } + + property FileView _current: FileView { + path: root._blDev ? root._blDev + "/brightness" : "" + watchChanges: true + onFileChanged: reload() + onLoaded: root._update() + } + + property FileView _max: FileView { + path: root._blDev ? root._blDev + "/max_brightness" : "" + onLoaded: root._update() + } + + function _update() { + const c = parseInt(_current.text()); + const m = parseInt(_max.text()); + if (m > 0) + percent = Math.round((c / m) * 100); + } + + property Process _adjProc: Process { + property string cmd: "" + command: ["sh", "-c", cmd] + onRunningChanged: if (!running && cmd !== "") + root._current.reload() + } +} diff --git a/shell/services/BluetoothService.qml b/shell/services/BluetoothService.qml new file mode 100644 index 0000000..e648538 --- /dev/null +++ b/shell/services/BluetoothService.qml @@ -0,0 +1,126 @@ +pragma Singleton + +import QtQuick +import Quickshell.Io +import "." as S + +QtObject { + id: root + + // Adapter state: "unavailable" | "off" | "on" | "connected" + property string state: "unavailable" + property string device: "" + property int batteryPct: -1 + + // Paired device list and power state (for menu) + property var devices: [] + property bool enabled: true + + function refresh() { + _statusProc.running = true; + _scannerProc.running = true; + } + + function setPower(on) { + _powerProc._action = on ? "on" : "off"; + _powerProc.running = true; + } + + function toggleDevice(mac, connect) { + _toggleProc._action = connect ? "connect" : "disconnect"; + _toggleProc._mac = mac; + _toggleProc.running = true; + } + + // Status polling (bar icon state) + property Process _statusProc: Process { + running: S.Modules.bluetooth.enable + command: ["sh", "-c", "s=$(bluetoothctl show 2>/dev/null); " + "[ -z \"$s\" ] && echo unavailable && exit; " + "echo \"$s\" | grep -q 'Powered: yes' || { echo off:; exit; }; " + "info=$(bluetoothctl info 2>/dev/null); " + "d=$(echo \"$info\" | awk -F': ' '/\\tName:/{n=$2}/Connected: yes/{c=1}END{if(c)print n}'); " + "[ -n \"$d\" ] && echo \"connected:$d\" || { echo on:; exit; }; " + "bat=$(echo \"$info\" | awk -F': ' '/Battery Percentage.*\\(/{gsub(/[^0-9]/,\"\",$2);print $2}'); " + "[ -n \"$bat\" ] && echo \"bat:$bat\""] + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + const t = lines[0] || ""; + const sep = t.indexOf(":"); + root.state = sep === -1 ? t : t.slice(0, sep); + root.device = sep === -1 ? "" : t.slice(sep + 1); + root.batteryPct = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].startsWith("bat:")) + root.batteryPct = parseInt(lines[i].slice(4)) || -1; + } + } + } + } + + // Event-driven: watch BlueZ DBus property changes + property Process _monitor: Process { + running: S.Modules.bluetooth.enable + command: ["sh", "-c", "dbus-monitor --system \"interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'\" 2>/dev/null"] + stdout: SplitParser { + splitMarker: "\n" + onRead: _debounce.restart() + } + } + + property Timer _debounce: Timer { + interval: 500 + onTriggered: root.refresh() + } + + property Timer _fallbackPoll: Timer { + interval: 60000 + running: S.Modules.bluetooth.enable + repeat: true + onTriggered: root.refresh() + } + + // Paired device scanner (for menu) + property Process _scannerProc: Process { + command: ["sh", "-c", "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2; exit}';" + "echo '---DEVICES---';" + "bluetoothctl devices Paired 2>/dev/null | while read -r _ mac name; do " + "info=$(bluetoothctl info \"$mac\" 2>/dev/null); " + "conn=$(echo \"$info\" | grep -c 'Connected: yes'); " + "bat=$(echo \"$info\" | awk -F'[(): ]' '/Battery Percentage/{for(i=1;i<=NF;i++) if($i+0==$i && $i!=\"\") print $i}'); " + "echo \"$mac:$conn:${bat:-}:$name\"; " + "done"] + stdout: StdioCollector { + onStreamFinished: { + const sections = text.split("---DEVICES---"); + root.enabled = (sections[0] || "").trim() === "yes"; + + const devs = []; + for (const line of (sections[1] || "").trim().split("\n")) { + if (!line) + continue; + const i1 = line.indexOf(":"); + const i2 = line.indexOf(":", i1 + 1); + const i3 = line.indexOf(":", i2 + 1); + if (i3 < 0) + continue; + devs.push({ + mac: line.slice(0, i1), + connected: line.slice(i1 + 1, i2) === "1", + battery: parseInt(line.slice(i2 + 1, i3)) || -1, + name: line.slice(i3 + 1) + }); + } + devs.sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + return a.name.localeCompare(b.name); + }); + root.devices = devs; + } + } + } + + // Action processes + property Process _powerProc: Process { + property string _action: "" + command: ["bluetoothctl", "power", _action] + onRunningChanged: if (!running) + root.refresh() + } + + property Process _toggleProc: Process { + property string _action: "" + property string _mac: "" + command: ["bluetoothctl", _action, _mac] + onRunningChanged: if (!running) + root.refresh() + } +} diff --git a/shell/services/IdleInhibitService.qml b/shell/services/IdleInhibitService.qml new file mode 100644 index 0000000..f74878e --- /dev/null +++ b/shell/services/IdleInhibitService.qml @@ -0,0 +1,31 @@ +pragma Singleton + +import QtQuick +import Quickshell.Io + +QtObject { + id: root + + property bool active: false + property string inhibitors: "" + + function toggle() { + active = !active; + } + + function refreshInhibitors() { + _listProc.running = true; + } + + property Process _inhibitor: Process { + command: ["systemd-inhibit", "--what=idle", "--who=nova-shell", "--why=user", "sleep", "infinity"] + running: root.active + } + + property Process _listProc: Process { + command: ["sh", "-c", "systemd-inhibit --list 2>/dev/null | grep -i idle | awk '{print $NF}' | sort -u | tr '\\n' ', ' | sed 's/, $//'"] + stdout: StdioCollector { + onStreamFinished: root.inhibitors = text.trim() ? "Blocked by: " + text.trim() : "" + } + } +} diff --git a/shell/services/MprisService.qml b/shell/services/MprisService.qml new file mode 100644 index 0000000..9f06ead --- /dev/null +++ b/shell/services/MprisService.qml @@ -0,0 +1,21 @@ +pragma Singleton + +import QtQuick +import Quickshell.Services.Mpris + +QtObject { + id: root + + readonly property var players: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused) + property int playerIdx: 0 + readonly property MprisPlayer player: players[playerIdx] ?? players[0] ?? null + readonly property bool playing: player?.playbackState === MprisPlaybackState.Playing + + // Reset index if current player disappears + onPlayersChanged: if (playerIdx >= players.length) + playerIdx = 0 + + function switchPlayer(idx) { + playerIdx = idx; + } +} diff --git a/shell/services/NetworkService.qml b/shell/services/NetworkService.qml new file mode 100644 index 0000000..2c2b531 --- /dev/null +++ b/shell/services/NetworkService.qml @@ -0,0 +1,180 @@ +pragma Singleton + +import QtQuick +import Quickshell.Io +import "." as S + +QtObject { + id: root + + // Connection state + property string ifname: "" + property string essid: "" + property string state: "disconnected" // "disconnected" | "wifi" | "eth" | "linked" + property string ipAddr: "" + property string signal: "" + + // Wi-Fi networks and radio state + property var networks: [] + property bool wifiEnabled: true + + function refresh() { + _statusProc.running = true; + _scannerProc.running = true; + } + + function setWifi(enabled) { + _radioProc._state = enabled ? "on" : "off"; + _radioProc.running = true; + } + + function connectNetwork(uuid) { + _connectProc._uuid = uuid; + _connectProc.running = true; + } + + function disconnectNetwork(uuid) { + _disconnectProc._uuid = uuid; + _disconnectProc.running = true; + } + + // Status polling + property Process _statusProc: Process { + running: S.Modules.network.enable + command: ["sh", "-c", "line=$(nmcli -t -f NAME,TYPE,DEVICE connection show --active 2>/dev/null | head -1); if [ -z \"$line\" ]; then dev=$(nmcli -t -f DEVICE,STATE device 2>/dev/null | grep ':connected' | grep -v ':unmanaged\\|:unavailable\\|:disconnected\\|:connecting' | head -1 | cut -d: -f1); [ -n \"$dev\" ] && line=\"linked:linked:$dev\"; fi; [ -z \"$line\" ] && exit 0; echo \"$line\"; dev=$(echo \"$line\" | cut -d: -f3); ip=$(nmcli -t -f IP4.ADDRESS device show \"$dev\" 2>/dev/null | head -1 | cut -d: -f2); echo \"ip:${ip:-}\"; sig=$(nmcli -t -f GENERAL.SIGNAL device show \"$dev\" 2>/dev/null | head -1 | cut -d: -f2); echo \"sig:${sig:-}\""] + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + if (!lines[0]) { + root.state = "disconnected"; + root.essid = ""; + root.ifname = ""; + root.ipAddr = ""; + root.signal = ""; + return; + } + const parts = lines[0].split(":"); + root.essid = parts[0] || ""; + root.ifname = parts[2] || ""; + if ((parts[1] || "").includes("wireless")) + root.state = "wifi"; + else if (parts[0] === "linked") + root.state = "linked"; + else + root.state = "eth"; + root.ipAddr = ""; + root.signal = ""; + for (let i = 1; i < lines.length; i++) { + if (lines[i].startsWith("ip:")) + root.ipAddr = lines[i].slice(3); + else if (lines[i].startsWith("sig:")) + root.signal = lines[i].slice(4); + } + } + } + } + + // Event-driven monitor + property Process _monitor: Process { + running: S.Modules.network.enable + command: ["nmcli", "monitor"] + stdout: SplitParser { + splitMarker: "\n" + onRead: _debounce.restart() + } + } + + property Timer _debounce: Timer { + interval: 300 + onTriggered: root.refresh() + } + + // Fallback poll + property Timer _fallbackPoll: Timer { + interval: 60000 + running: S.Modules.network.enable + repeat: true + onTriggered: root.refresh() + } + + // Wi-Fi scanner (connections + available SSIDs) + property Process _scannerProc: Process { + running: true + command: ["sh", "-c", "echo '---RADIO---';" + "nmcli radio wifi 2>/dev/null;" + "echo '---CONNS---';" + "nmcli -t -f NAME,UUID,TYPE,ACTIVE connection show 2>/dev/null;" + "echo '---WIFI---';" + "nmcli -t -f SSID,SIGNAL device wifi list --rescan no 2>/dev/null"] + stdout: StdioCollector { + onStreamFinished: { + const radioSection = text.split("---CONNS---")[0].split("---RADIO---")[1] || ""; + root.wifiEnabled = radioSection.trim() === "enabled"; + + const sections = text.split("---WIFI---"); + const connLines = (sections[0] || "").split("---CONNS---")[1] || ""; + const wifiLines = sections[1] || ""; + + const visible = {}; + for (const l of wifiLines.trim().split("\n")) { + if (!l) + continue; + const parts = l.split(":"); + const ssid = parts[0]; + if (ssid) + visible[ssid] = parseInt(parts[1]) || 0; + } + + const nets = []; + for (const l of connLines.trim().split("\n")) { + if (!l) + continue; + const parts = l.split(":"); + const name = parts[0]; + const uuid = parts[1]; + const type = parts[2] || ""; + const active = parts[3] === "yes"; + const isWifi = type.includes("wireless"); + + if (isWifi && !(name in visible)) + continue; + + nets.push({ + name: name, + uuid: uuid, + isWifi: isWifi, + active: active, + signal: isWifi ? (visible[name] || 0) : -1 + }); + } + + nets.sort((a, b) => { + if (a.active !== b.active) + return a.active ? -1 : 1; + if (a.signal >= 0 && b.signal >= 0) + return b.signal - a.signal; + return a.name.localeCompare(b.name); + }); + + root.networks = nets; + } + } + } + + // Action processes + property Process _radioProc: Process { + property string _state: "" + command: ["nmcli", "radio", "wifi", _state] + onRunningChanged: if (!running) + root.refresh() + } + + property Process _connectProc: Process { + property string _uuid: "" + command: ["nmcli", "connection", "up", _uuid] + onRunningChanged: if (!running) + root.refresh() + } + + property Process _disconnectProc: Process { + property string _uuid: "" + command: ["nmcli", "connection", "down", _uuid] + onRunningChanged: if (!running) + root.refresh() + } +} diff --git a/shell/services/qmldir b/shell/services/qmldir index d23b48f..57dad10 100644 --- a/shell/services/qmldir +++ b/shell/services/qmldir @@ -7,3 +7,8 @@ singleton PowerProfileService 1.0 PowerProfileService.qml singleton NotifService 1.0 NotifService.qml NotifItem 1.0 NotifItem.qml singleton LockService 1.0 LockService.qml +singleton BacklightService 1.0 BacklightService.qml +singleton MprisService 1.0 MprisService.qml +singleton NetworkService 1.0 NetworkService.qml +singleton BluetoothService 1.0 BluetoothService.qml +singleton IdleInhibitService 1.0 IdleInhibitService.qml