Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nvidia-optimus/Assets/nvidia-eye.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
86 changes: 86 additions & 0 deletions nvidia-optimus/BarWidget.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import qs.Commons
import qs.Services.UI
import qs.Widgets

// Bar button: the NVIDIA "eye" mark, tinted to the active Noctalia theme — the accent
// colour (Color.mPrimary) when the dGPU is awake / driving a display, and a neutral
// colour (Color.mOnSurface) when it is asleep. Hidden when no NVIDIA dGPU is present.
NIconButton {
id: root

property var pluginApi: null

property ShellScreen screen
property string widgetId: ""
property string section: ""
property int sectionWidgetIndex: -1
property int sectionWidgetsCount: 0

readonly property var main: pluginApi?.mainInstance
readonly property string rt: main ? main.runtime : "unknown"
readonly property bool ext: main ? main.external : false
readonly property string md: main ? main.mode : "battery"
readonly property bool present: main ? main.present : true
readonly property bool lit: root.ext || root.rt === "active"

visible: root.present
icon: "" // hidden: we draw the tinted NVIDIA mark below

tooltipText: {
var api = root.pluginApi;
if (root.ext)
return api?.tr("bar.tooltip-external");
var state = (root.rt === "active") ? api?.tr("common.state-active") : api?.tr("common.state-asleep");
var mode = (root.md === "hdmi") ? api?.tr("common.mode-hdmi") : api?.tr("common.mode-battery");
return api?.tr("bar.tooltip", {
"state": state,
"mode": mode
});
}
tooltipDirection: BarService.getTooltipDirection(screen?.name)
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
applyUiScale: false
customRadius: Style.radiusL
colorBg: Style.capsuleColor
border.color: Style.capsuleBorderColor
border.width: Style.capsuleBorderWidth

Item {
id: mark
anchors.centerIn: parent
width: Math.round(root.buttonSize * 0.9)
height: width

Image {
id: eyeImg
anchors.fill: parent
source: Qt.resolvedUrl("Assets/nvidia-eye.svg")
fillMode: Image.PreserveAspectFit
sourceSize.width: mark.width
sourceSize.height: mark.height
smooth: true
mipmap: true
visible: false // shown via the tinting effect below
}

MultiEffect {
anchors.fill: eyeImg
source: eyeImg
colorization: 1.0
colorizationColor: root.lit ? Color.mPrimary : Color.mOnSurface
Behavior on colorizationColor {
ColorAnimation {
duration: Style.animationFast
}
}
}
}

onClicked: {
if (pluginApi)
pluginApi.openPanel(root.screen, this);
}
}
109 changes: 109 additions & 0 deletions nvidia-optimus/Main.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons

// Headless logic + polling for the NVIDIA Optimus plugin.
// The background poll runs the bundled `scripts/gpu-stat` in SYSFS-ONLY mode, so it
// never wakes — or holds awake — a sleeping dGPU. Detailed nvidia-smi stats are
// polled by the Panel only while it is open.
Item {
id: root

property var pluginApi: null

// Bundled helper scripts, resolved relative to this plugin's directory and run via
// bash (so no execute bit is required after a plain `git clone`).
readonly property string pluginDir: Qt.resolvedUrl(".").toString().replace(/^file:\/\//, "")
readonly property string statCmd: root.pluginDir + "scripts/gpu-stat"
readonly property string toggleCmd: root.pluginDir + "scripts/gpu-toggle"

property var stats: ({
"runtime": "unknown",
"mode": "battery",
"pending": "battery",
"external": "none"
})

readonly property string runtime: (stats && stats.runtime) ? stats.runtime : "unknown"
readonly property string mode: (stats && stats.mode) ? stats.mode : "battery" // live
readonly property string pending: (stats && stats.pending) ? stats.pending : mode // next login
readonly property bool present: runtime !== "absent"
readonly property bool active: runtime === "active"
readonly property bool external: !!(stats && stats.external && stats.external !== "none")
readonly property bool needsRestart: pending !== mode
property bool busy: false

function refresh() {
if (statProc.running)
return;
statProc.command = ["bash", root.statCmd];
statProc.running = true;
}

function setMode(m) {
busy = true;
ctlProc.command = ["bash", root.toggleCmd, m];
ctlProc.running = true;
refresh();
}

function toggle() {
if (busy)
return;
setMode(pending === "hdmi" ? "battery" : "hdmi");
}

function apply() {
applyProc.running = true; // restarts the Hyprland session to pick up the env change
}

// qs -c noctalia-shell ipc call plugin:nvidia-optimus toggle (open panel)
// qs -c noctalia-shell ipc call plugin:nvidia-optimus mode (flip Battery/HDMI)
IpcHandler {
target: "plugin:nvidia-optimus"

function toggle(): void {
if (root.pluginApi)
root.pluginApi.withCurrentScreen(screen => root.pluginApi.togglePanel(screen));
}

function mode(): void {
root.toggle();
}
}

Process {
id: statProc
stdout: StdioCollector {
onStreamFinished: {
try {
root.stats = JSON.parse(this.text);
} catch (e) {
Logger.w("NvidiaOptimus", "Failed to parse gpu-stat output");
}
}
}
}

Process {
id: ctlProc
onExited: function (code) {
root.busy = false;
root.refresh();
}
}

Process {
id: applyProc
command: ["hyprctl", "dispatch", "exit"]
}

Timer {
interval: 2000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
}
Loading