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
|
||||
|
||||
- Shows current GPU mode in the system tray (chip icon: red/green split outline for Hybrid, solid green for NVIDIA 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
|
||||
- Switch between **Hybrid** (AMD iGPU drives internal display, NVIDIA exposes HDMI) and **NVIDIA only** (dGPU drives everything) with one click
|
||||
- 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 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 (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`)
|
||||
- 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
|
||||
- **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
|
||||
|
||||
## Compatibility
|
||||
|
|
|
|||
153
main.py
153
main.py
|
|
@ -1,19 +1,20 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QMessageBox
|
||||
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QAction, QPen
|
||||
from PyQt6.QtCore import Qt, QTimer, QRect
|
||||
|
||||
MODE_LABELS = {
|
||||
'hybrid': 'Hybrid (AMD + NVIDIA)',
|
||||
'dgpu': 'NVIDIA only',
|
||||
'hybrid': 'Hybrid (iGPU + dGPU)',
|
||||
'dgpu': 'dGPU only',
|
||||
'unknown': 'Unknown',
|
||||
}
|
||||
|
||||
SWITCH_LABELS = {
|
||||
'hybrid': 'Switch to Hybrid (AMD + NVIDIA)',
|
||||
'dgpu': 'Switch to NVIDIA only',
|
||||
'hybrid': 'Switch to Hybrid (iGPU + dGPU)',
|
||||
'dgpu': 'Switch to dGPU only',
|
||||
}
|
||||
|
||||
AMD_RED = QColor('#EF5350')
|
||||
|
|
@ -22,6 +23,32 @@ GREY = QColor('#9E9E9E')
|
|||
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():
|
||||
"""Returns 'dgpu' if any user process has /dev/nvidia0 open, else 'igpu'.
|
||||
runtime_status is unreliable here because nvidia-drm.modeset=1 keeps the
|
||||
|
|
@ -62,32 +89,6 @@ def switch_mode(target):
|
|||
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'):
|
||||
size = 22
|
||||
px = QPixmap(size, size)
|
||||
|
|
@ -96,17 +97,65 @@ def make_icon(mode, pending=False, active_gpu='igpu'):
|
|||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
if mode == 'hybrid':
|
||||
fill = QColor(NV_GREEN if active_gpu == 'dgpu' else AMD_RED)
|
||||
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))
|
||||
c_left, c_right = AMD_RED, NV_GREEN
|
||||
elif mode == 'dgpu':
|
||||
_draw_chip(p, NV_GREEN)
|
||||
c_left = c_right = NV_GREEN
|
||||
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:
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
|
|
@ -125,6 +174,7 @@ class GpuModeTray:
|
|||
self.active_mode = get_mode()
|
||||
self.pending_mode = 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._build_menu()
|
||||
self.tray.setContextMenu(self.menu)
|
||||
|
|
@ -187,7 +237,9 @@ class GpuModeTray:
|
|||
self.status_item.setText(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
|
||||
for key, action in [('hybrid', self.hybrid_action), ('dgpu', self.dgpu_action)]:
|
||||
|
|
@ -201,9 +253,9 @@ class GpuModeTray:
|
|||
action.setText(SWITCH_LABELS[key])
|
||||
action.setEnabled(self.active_mode != key)
|
||||
|
||||
in_hybrid = self.active_mode == 'hybrid'
|
||||
self.sep_dgpu_procs.setVisible(in_hybrid)
|
||||
self.dgpu_procs_action.setVisible(in_hybrid)
|
||||
dgpu_active = self.active_gpu == 'dgpu' or self.active_mode == 'dgpu'
|
||||
self.sep_dgpu_procs.setVisible(dgpu_active)
|
||||
self.dgpu_procs_action.setVisible(dgpu_active)
|
||||
|
||||
self.sep_pending.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]}')
|
||||
|
||||
def _show_dgpu_processes(self):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['nvidia-smi', '--query-compute-apps=pid,name,used_memory', '--format=csv,noheader'],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
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}'
|
||||
procs = get_dgpu_processes()
|
||||
if procs:
|
||||
text = '\n'.join(f'{name} (PID {pid})' for pid, name in procs)
|
||||
else:
|
||||
text = 'No processes currently using the dGPU.'
|
||||
QMessageBox.information(None, 'dGPU processes', text)
|
||||
|
||||
def _refresh_active_gpu(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue