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:
pongsn 2026-06-30 00:58:48 -05:00
parent b03f0a182d
commit 4e13f08b07
2 changed files with 105 additions and 57 deletions

View file

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

153
main.py
View file

@ -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 = 'No processes currently using the dGPU.'
text = r.stdout.strip()
if not text:
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):