Redesign icon, fix dGPU process detection, polish menu
- Replace chip-outline icon with a GPU card shape (PCIe card body, two cooling fans, gold contact fingers) — fixes color stripping introduced by Qt 6.11 + KDE Plasma 6.6 tray icon colorization - Active GPU indicator: yellow-tinted fan disc + bright ring on the active side in Hybrid mode (replaces the invisible 2px hub dot) - dGPU process list now scans /proc/<pid>/fd for open NVIDIA devices instead of nvidia-smi --query-compute-apps, so graphics/rendering processes (Lutris, games, Vulkan/OpenGL apps) are included - "Show dGPU processes" menu item is now hidden unless the dGPU is actually active (was shown whenever mode == hybrid) - Menu labels changed from AMD/NVIDIA to generic iGPU/dGPU - Popup dialogs now inherit the tray icon via QApplication.setWindowIcon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b03f0a182d
commit
4e13f08b07
2 changed files with 105 additions and 57 deletions
|
|
@ -4,13 +4,14 @@ A lightweight KDE system tray application for switching GPU modes on Lenovo Legi
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Shows current GPU mode in the system tray (chip icon: red/green split outline for Hybrid, solid green for NVIDIA only)
|
- Shows current GPU mode in the system tray — GPU card icon (PCIe card body with two cooling fans and gold contact fingers): red/green split for Hybrid, solid green for dGPU only
|
||||||
- **In Hybrid mode**, the chip body fills red (iGPU active) or green (dGPU active) to show which GPU is currently doing work — updates every 5 seconds via `/dev/nvidia0` usage detection
|
- **In Hybrid mode**, the fan on the active GPU's side glows yellow to show which GPU is currently doing work — updates every 5 seconds via `/dev/nvidia*` usage detection
|
||||||
- Switch between **Hybrid** (AMD iGPU drives internal display, NVIDIA exposes HDMI) and **NVIDIA only** (dGPU drives everything) with one click
|
- Switch between **Hybrid (iGPU + dGPU)** and **dGPU only** with one click — menu labels are hardware-agnostic (iGPU/dGPU rather than AMD/NVIDIA)
|
||||||
- No password prompt for the active desktop session (polkit `allow_active: yes`)
|
- No password prompt for the active desktop session (polkit `allow_active: yes`)
|
||||||
- Tracks pending mode switches before reboot — shows an orange badge on the icon and allows cancelling the switch
|
- Tracks pending mode switches before reboot — shows an orange badge on the icon and allows cancelling the switch
|
||||||
- Triggers a reboot prompt after switching; also offers "Reboot now" from the tray menu
|
- Triggers a reboot prompt after switching; also offers "Reboot now" from the tray menu
|
||||||
- **In Hybrid mode**, shows a "Show dGPU processes" menu item listing which processes are currently using the dGPU (via `nvidia-smi`)
|
- Shows a **"Show dGPU processes"** menu item only when the dGPU is actually in use; lists all processes with an NVIDIA device open (graphics and compute — detected via `/proc/<pid>/fd`, not just CUDA via `nvidia-smi`)
|
||||||
|
- Popup dialogs share the same tray icon
|
||||||
- Installs itself as a KDE autostart entry
|
- Installs itself as a KDE autostart entry
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
|
||||||
151
main.py
151
main.py
|
|
@ -1,19 +1,20 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QMessageBox
|
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QMessageBox
|
||||||
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QAction, QPen
|
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QAction, QPen
|
||||||
from PyQt6.QtCore import Qt, QTimer, QRect
|
from PyQt6.QtCore import Qt, QTimer, QRect
|
||||||
|
|
||||||
MODE_LABELS = {
|
MODE_LABELS = {
|
||||||
'hybrid': 'Hybrid (AMD + NVIDIA)',
|
'hybrid': 'Hybrid (iGPU + dGPU)',
|
||||||
'dgpu': 'NVIDIA only',
|
'dgpu': 'dGPU only',
|
||||||
'unknown': 'Unknown',
|
'unknown': 'Unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
SWITCH_LABELS = {
|
SWITCH_LABELS = {
|
||||||
'hybrid': 'Switch to Hybrid (AMD + NVIDIA)',
|
'hybrid': 'Switch to Hybrid (iGPU + dGPU)',
|
||||||
'dgpu': 'Switch to NVIDIA only',
|
'dgpu': 'Switch to dGPU only',
|
||||||
}
|
}
|
||||||
|
|
||||||
AMD_RED = QColor('#EF5350')
|
AMD_RED = QColor('#EF5350')
|
||||||
|
|
@ -22,6 +23,32 @@ GREY = QColor('#9E9E9E')
|
||||||
BADGE = QColor('#FFA726')
|
BADGE = QColor('#FFA726')
|
||||||
|
|
||||||
|
|
||||||
|
_NVIDIA_DEVS = {'/dev/nvidia0', '/dev/nvidiactl', '/dev/nvidia-modeset', '/dev/nvidia-uvm'}
|
||||||
|
|
||||||
|
def get_dgpu_processes():
|
||||||
|
"""Return sorted list of (pid, name) for every process with an NVIDIA device open."""
|
||||||
|
found = {}
|
||||||
|
try:
|
||||||
|
for entry in os.scandir('/proc'):
|
||||||
|
if not entry.name.isdigit():
|
||||||
|
continue
|
||||||
|
pid = entry.name
|
||||||
|
try:
|
||||||
|
for fd_entry in os.scandir(f'/proc/{pid}/fd'):
|
||||||
|
try:
|
||||||
|
if os.readlink(fd_entry.path) in _NVIDIA_DEVS:
|
||||||
|
with open(f'/proc/{pid}/comm') as f:
|
||||||
|
found[pid] = f.read().strip()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except (PermissionError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sorted(found.items(), key=lambda x: int(x[0]))
|
||||||
|
|
||||||
|
|
||||||
def get_active_gpu():
|
def get_active_gpu():
|
||||||
"""Returns 'dgpu' if any user process has /dev/nvidia0 open, else 'igpu'.
|
"""Returns 'dgpu' if any user process has /dev/nvidia0 open, else 'igpu'.
|
||||||
runtime_status is unreliable here because nvidia-drm.modeset=1 keeps the
|
runtime_status is unreliable here because nvidia-drm.modeset=1 keeps the
|
||||||
|
|
@ -62,32 +89,6 @@ def switch_mode(target):
|
||||||
return r.returncode == 0
|
return r.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def _draw_chip(painter, color, clip=None):
|
|
||||||
if clip:
|
|
||||||
painter.save()
|
|
||||||
painter.setClipRect(clip)
|
|
||||||
pen = QPen(color, 1.5, Qt.PenStyle.SolidLine,
|
|
||||||
Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)
|
|
||||||
painter.setPen(pen)
|
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
||||||
# Body
|
|
||||||
painter.drawRoundedRect(5, 5, 12, 12, 2, 2)
|
|
||||||
# Left pins
|
|
||||||
painter.drawRect(2, 8, 3, 2)
|
|
||||||
painter.drawRect(2, 12, 3, 2)
|
|
||||||
# Right pins
|
|
||||||
painter.drawRect(17, 8, 3, 2)
|
|
||||||
painter.drawRect(17, 12, 3, 2)
|
|
||||||
# Top pins
|
|
||||||
painter.drawRect(8, 2, 2, 3)
|
|
||||||
painter.drawRect(12, 2, 2, 3)
|
|
||||||
# Bottom pins
|
|
||||||
painter.drawRect(8, 17, 2, 3)
|
|
||||||
painter.drawRect(12, 17, 2, 3)
|
|
||||||
if clip:
|
|
||||||
painter.restore()
|
|
||||||
|
|
||||||
|
|
||||||
def make_icon(mode, pending=False, active_gpu='igpu'):
|
def make_icon(mode, pending=False, active_gpu='igpu'):
|
||||||
size = 22
|
size = 22
|
||||||
px = QPixmap(size, size)
|
px = QPixmap(size, size)
|
||||||
|
|
@ -96,17 +97,65 @@ def make_icon(mode, pending=False, active_gpu='igpu'):
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
if mode == 'hybrid':
|
if mode == 'hybrid':
|
||||||
fill = QColor(NV_GREEN if active_gpu == 'dgpu' else AMD_RED)
|
c_left, c_right = AMD_RED, NV_GREEN
|
||||||
fill.setAlpha(90)
|
|
||||||
p.setPen(Qt.PenStyle.NoPen)
|
|
||||||
p.setBrush(fill)
|
|
||||||
p.drawRoundedRect(5, 5, 12, 12, 2, 2)
|
|
||||||
_draw_chip(p, AMD_RED, QRect(0, 0, 11, size))
|
|
||||||
_draw_chip(p, NV_GREEN, QRect(11, 0, 11, size))
|
|
||||||
elif mode == 'dgpu':
|
elif mode == 'dgpu':
|
||||||
_draw_chip(p, NV_GREEN)
|
c_left = c_right = NV_GREEN
|
||||||
else:
|
else:
|
||||||
_draw_chip(p, GREY)
|
c_left = c_right = GREY
|
||||||
|
|
||||||
|
def with_alpha(color, alpha):
|
||||||
|
c = QColor(color); c.setAlpha(alpha); return c
|
||||||
|
|
||||||
|
# ── Card body (landscape PCIe card) ────────────────────────
|
||||||
|
card = QRect(1, 4, 20, 12)
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(with_alpha(c_left, 200))
|
||||||
|
p.save(); p.setClipRect(QRect(0, 0, 11, size))
|
||||||
|
p.drawRoundedRect(card, 2, 2)
|
||||||
|
p.restore()
|
||||||
|
p.setBrush(with_alpha(c_right, 200))
|
||||||
|
p.save(); p.setClipRect(QRect(11, 0, 11, size))
|
||||||
|
p.drawRoundedRect(card, 2, 2)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
# ── Two cooling fans ────────────────────────────────────────
|
||||||
|
fan_r, fan_cy = 4, 10
|
||||||
|
# In hybrid mode highlight the hub of whichever GPU is active
|
||||||
|
active_fx = 16 if (mode == 'hybrid' and active_gpu == 'dgpu') else 6
|
||||||
|
|
||||||
|
for fx in [6, 16]:
|
||||||
|
is_active = (fx == active_fx and mode == 'hybrid')
|
||||||
|
# Disc — tinted yellow for active fan, plain white otherwise
|
||||||
|
disc = QColor(255, 220, 80, 140) if is_active else QColor(255, 255, 255, 140)
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(disc)
|
||||||
|
p.drawEllipse(fx - fan_r, fan_cy - fan_r, fan_r * 2, fan_r * 2)
|
||||||
|
# Blade cross
|
||||||
|
p.setPen(QPen(QColor(0, 0, 0, 110), 0.8))
|
||||||
|
p.drawLine(fx, fan_cy - fan_r + 1, fx, fan_cy + fan_r - 1)
|
||||||
|
p.drawLine(fx - fan_r + 1, fan_cy, fx + fan_r - 1, fan_cy)
|
||||||
|
# Circle outline — bright yellow ring for active, darker for inactive
|
||||||
|
if is_active:
|
||||||
|
p.setPen(QPen(QColor(255, 230, 100, 230), 1.2))
|
||||||
|
else:
|
||||||
|
p.setPen(QPen(QColor(0, 0, 0, 130), 1.0))
|
||||||
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
p.drawEllipse(fx - fan_r, fan_cy - fan_r, fan_r * 2, fan_r * 2)
|
||||||
|
# Hub dot
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(QColor(255, 255, 255, 200))
|
||||||
|
p.drawEllipse(fx - 1, fan_cy - 1, 2, 2)
|
||||||
|
|
||||||
|
# ── Card outline ────────────────────────────────────────────
|
||||||
|
p.setPen(QPen(QColor(0, 0, 0, 70), 1.0))
|
||||||
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
p.drawRoundedRect(card, 2, 2)
|
||||||
|
|
||||||
|
# ── PCIe gold contact fingers ───────────────────────────────
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(QColor(200, 160, 50, 230))
|
||||||
|
for tx in [3, 6, 9, 12, 15]:
|
||||||
|
p.drawRect(tx, 16, 2, 3)
|
||||||
|
|
||||||
if pending:
|
if pending:
|
||||||
p.setPen(Qt.PenStyle.NoPen)
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
|
@ -125,6 +174,7 @@ class GpuModeTray:
|
||||||
self.active_mode = get_mode()
|
self.active_mode = get_mode()
|
||||||
self.pending_mode = None
|
self.pending_mode = None
|
||||||
self.active_gpu = get_active_gpu() if self.active_mode == 'hybrid' else None
|
self.active_gpu = get_active_gpu() if self.active_mode == 'hybrid' else None
|
||||||
|
self.app.setWindowIcon(make_icon(self.active_mode, active_gpu=self.active_gpu))
|
||||||
self.tray = QSystemTrayIcon(make_icon(self.active_mode, active_gpu=self.active_gpu))
|
self.tray = QSystemTrayIcon(make_icon(self.active_mode, active_gpu=self.active_gpu))
|
||||||
self._build_menu()
|
self._build_menu()
|
||||||
self.tray.setContextMenu(self.menu)
|
self.tray.setContextMenu(self.menu)
|
||||||
|
|
@ -187,7 +237,9 @@ class GpuModeTray:
|
||||||
self.status_item.setText(f'GPU mode: {active_label}')
|
self.status_item.setText(f'GPU mode: {active_label}')
|
||||||
self.tray.setToolTip(f'GPU mode: {active_label}')
|
self.tray.setToolTip(f'GPU mode: {active_label}')
|
||||||
|
|
||||||
self.tray.setIcon(make_icon(self.active_mode, pending=pending, active_gpu=self.active_gpu))
|
icon = make_icon(self.active_mode, pending=pending, active_gpu=self.active_gpu)
|
||||||
|
self.tray.setIcon(icon)
|
||||||
|
self.app.setWindowIcon(icon)
|
||||||
|
|
||||||
# Switch action labels and enabled state
|
# Switch action labels and enabled state
|
||||||
for key, action in [('hybrid', self.hybrid_action), ('dgpu', self.dgpu_action)]:
|
for key, action in [('hybrid', self.hybrid_action), ('dgpu', self.dgpu_action)]:
|
||||||
|
|
@ -201,9 +253,9 @@ class GpuModeTray:
|
||||||
action.setText(SWITCH_LABELS[key])
|
action.setText(SWITCH_LABELS[key])
|
||||||
action.setEnabled(self.active_mode != key)
|
action.setEnabled(self.active_mode != key)
|
||||||
|
|
||||||
in_hybrid = self.active_mode == 'hybrid'
|
dgpu_active = self.active_gpu == 'dgpu' or self.active_mode == 'dgpu'
|
||||||
self.sep_dgpu_procs.setVisible(in_hybrid)
|
self.sep_dgpu_procs.setVisible(dgpu_active)
|
||||||
self.dgpu_procs_action.setVisible(in_hybrid)
|
self.dgpu_procs_action.setVisible(dgpu_active)
|
||||||
|
|
||||||
self.sep_pending.setVisible(pending)
|
self.sep_pending.setVisible(pending)
|
||||||
self.cancel_action.setVisible(pending)
|
self.cancel_action.setVisible(pending)
|
||||||
|
|
@ -212,16 +264,11 @@ class GpuModeTray:
|
||||||
self.cancel_action.setText(f'Cancel switch to {MODE_LABELS[self.pending_mode]}')
|
self.cancel_action.setText(f'Cancel switch to {MODE_LABELS[self.pending_mode]}')
|
||||||
|
|
||||||
def _show_dgpu_processes(self):
|
def _show_dgpu_processes(self):
|
||||||
try:
|
procs = get_dgpu_processes()
|
||||||
r = subprocess.run(
|
if procs:
|
||||||
['nvidia-smi', '--query-compute-apps=pid,name,used_memory', '--format=csv,noheader'],
|
text = '\n'.join(f'{name} (PID {pid})' for pid, name in procs)
|
||||||
capture_output=True, text=True, timeout=5,
|
else:
|
||||||
)
|
|
||||||
text = r.stdout.strip()
|
|
||||||
if not text:
|
|
||||||
text = 'No processes currently using the dGPU.'
|
text = 'No processes currently using the dGPU.'
|
||||||
except Exception as e:
|
|
||||||
text = f'Failed to query nvidia-smi:\n{e}'
|
|
||||||
QMessageBox.information(None, 'dGPU processes', text)
|
QMessageBox.information(None, 'dGPU processes', text)
|
||||||
|
|
||||||
def _refresh_active_gpu(self):
|
def _refresh_active_gpu(self):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue