For years I wanted a Windows launcher that felt like the fluid radial menus I kept seeing on macOS hold a button, a wheel of apps fans out under your cursor, flick to one, and you're done. Nothing on Windows felt that immediate. So I built it.
Nimbus is an open-source radial launcher for Windows. Hold the right mouse button anywhere on your desktop, and a translucent wheel appears beneath your cursor. Move to a slice and release to launch an app, open a folder or website, run a command, or dive into a submenu. It's written in Python with PySide6 and a fair amount of raw Win32.
This isn't a tutorial. It's a walkthrough of the four engineering problems that made Nimbus interesting to build—because each one taught me something about how Windows actually works underneath.
What Nimbus does
- Hold right-click → the wheel opens at your cursor. A quick right-click still shows the normal Windows menu.
- Move to a slice and release (or press
1–9) to trigger it. - Launch apps, open URLs and folders, run commands, and nest submenus.
- Slices show the real icons of your apps and websites.
- Runs quietly in the system tray, starts with Windows, single-instance, self-healing.
The stack: Python 3.10, PySide6 (Qt for Python) for the overlay and config UI, and ctypes straight into user32 / shell32 / gdi32 for everything Qt can't reach.
Problem 1: Detecting a global right-click hold without breaking right-click
This is the core of the whole thing, and it's deceptively hard.
I need to know when the right mouse button is pressed anywhere on screen, start a timer, and if it's still held a moment later, open the wheel. But I must not break a normal quick right-click — people still need their context menus.
The tool for global input is a low-level mouse hook (WH_MOUSE_LL) via SetWindowsHookExW. There's no PySide6 wrapper for it, so it's raw ctypes:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
WH_MOUSE_LL = 14
HOOKPROC = ctypes.CFUNCTYPE(ctypes.c_ssize_t, ctypes.c_int,
wintypes.WPARAM, wintypes.LPARAM)
# 64-bit gotcha: without this, the HHOOK handle gets truncated
user32.SetWindowsHookExW.restype = wintypes.HHOOK
hook = user32.SetWindowsHookExW(WH_MOUSE_LL, HOOKPROC(callback),
kernel32.GetModuleHandleW(None), 0)
The callback fires for every mouse event system-wide. The return value is the magic: return 1 to swallow the event (the app underneath never sees it), or pass it on with CallNextHookEx.
Here's the state machine, trimmed to the essentials:
def _handle(self, msg, info) -> bool:
if msg == WM_RBUTTONDOWN:
self._pending = True
self._timer.start() # QTimer(hold_ms), single-shot
return True # SWALLOW the real button-down
if msg == WM_RBUTTONUP:
if self.is_active(): # wheel is open -> commit the hovered slice
self.on_commit()
return True
if self._pending: # released early -> it was a normal click
self._timer.stop()
_synth_right_click() # replay it so the context menu still appears
return True
return False # let everything else through
The trick that makes it feel seamless: I swallow the real button-down immediately, so the app underneath never starts a selection or drag during the hold. Then:
- If the timer fires (still held) → open the wheel.
- If the button comes up first → it was a normal click, so I synthesize a fresh right-click with
SendInput. Injected events carry theLLMHF_INJECTEDflag, so my own hook ignores them and lets them flow through — and the context menu appears exactly as expected.
Two things bit me here:
- The callback runs on the thread that installed the hook, and that thread must pump a message loop. Qt's main thread already does, so installing the hook there just works.
-
Keep the callback tiny. Windows silently removes a low-level hook if the callback is too slow. So the callback only sets flags and starts/stops a
QTimer; the actual rendering happens later.
Problem 2: The acrylic blur that turned my entire screen black
I wanted the frosted-glass look — the desktop blurred behind the wheel. Windows has an (undocumented but widely used) API for it: SetWindowCompositionAttribute with ACCENT_ENABLE_ACRYLICBLURBEHIND.
I made the overlay a full-screen transparent window and applied acrylic to it. On my machine, the entire screen went solid black.
The lesson: on Windows 11, DWM disables acrylic blur for full-screen windows (and several window styles). Instead of blurring, the tint collapses into an opaque fill — black, in my case. The API even returns success; it just doesn't render what you expect.
The fix was to stop fighting it. I made the overlay a small circular window sized to just the wheel, positioned at the cursor, with a fully transparent background, and I paint the translucent disc myself with QPainter:
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
self.setAttribute(Qt.WA_TranslucentBackground, True)
# ...then draw a translucent card + ring + slices in paintEvent()
The desktop shows crisply around it. No blur, but no black box either — and honestly it looks cleaner and renders instantly on every machine.
Takeaway: a transparent window you paint yourself is far more portable than platform blur APIs. Sometimes removing complexity is the best optimization.
Problem 3: Showing the real Windows icons
Emoji placeholders looked amateur. I wanted the wheel to show the actual icon of each app and site.
App executables were easy — Qt exposes the system icon provider:
from PySide6.QtWidgets import QFileIconProvider
from PySide6.QtCore import QFileInfo
QFileIconProvider().icon(QFileInfo(r"C:\Windows\System32\calc.exe")).pixmap(30, 30)
Shell locations like the Recycle Bin were the interesting part. shell:RecycleBinFolder isn't a real path, so the icon provider falls back to a generic folder — which made "Recycle Bin" and "Files" look identical. The real icon lives in the shell namespace, not on disk. So I drop to Win32: parse the name into a PIDL, ask the shell for its HICON, then convert that to a QPixmap:
def shell_icon(name, size): # e.g. "shell:RecycleBinFolder"
pidl = ctypes.c_void_p()
shell32.SHParseDisplayName(name, None, ctypes.byref(pidl), 0, None)
sfi = SHFILEINFO()
shell32.SHGetFileInfoW(pidl, 0, ctypes.byref(sfi), ctypes.sizeof(sfi),
SHGFI_ICON | SHGFI_LARGEICON | SHGFI_PIDL)
return _hicon_to_pixmap(sfi.hIcon, size)
Converting the HICON means drawing it into a 32-bit DIB section with GDI and wrapping the raw BGRA bytes as a QImage. And the 64-bit gotcha showed up again: every GDI handle function needs restype/argtypes set, or ctypes truncates the handle to 32 bits and you get an OverflowError deep inside SelectObject.
Websites get their favicon fetched in a background thread (the site's own /favicon.ico first, with Google's favicon service as a fallback) and cached to disk, so opening the wheel never blocks on the network.
Problem 4: Turning a weekend project into something I use every day
A launcher that hooks global input has to be robust, because when it breaks it breaks everywhere. A few features nobody ever sees are what made this something I actually run all day:
- Single instance — a named Windows mutex; a second launch just points you at the tray icon. Two global hooks fighting is a bad time.
- Hook watchdog — Windows can silently drop a low-level hook. A timer checks whether the hook has seen events while the user is clearly active, and reinstalls it if not.
-
Logging — rotating file logs in
%APPDATA%, plus a globalsys.excepthook, because a windowed app has no console to print to. - Graceful failures — a broken config entry logs and shows a tray toast instead of crashing.
The features users never notice are often the ones that matter most.
What building Nimbus taught me
-
ctypes plus the Win32 docs will get you anywhere Qt won't — but set
restype/argtypeson every call. 64-bit handle truncation is the number one silent bug. -
Prefer painting over platform effects. A translucent window and
QPainterbeat the "official" blur API on portability and predictability. - Global-input tools live or die on robustness. Single-instance, watchdogs, and logging aren't optional when your failure mode is "the whole desktop feels broken."
- Nearly every hard problem was already documented somewhere. The challenge wasn't finding the APIs — it was understanding the concepts behind them.
Where it is now
The version in this post is the weekend build — the four problems that made it work. Since then Nimbus has grown into something I reach for dozens of times a day:
-
Type-to-search — start typing on the open wheel and it fuzzy-filters live; the ring re-segments to the matches and
Enterlaunches the top hit. This is what turned it from "menu" into "launcher." - Window management — snap, move, or maximize the window you were just in (left/right halves, thirds, center, next monitor) straight from the wheel.
- App-specific profiles — a different wheel depending on the app in front of you: a browser wheel in Chrome, a dev wheel in my editor.
- Gesture mode — a marking-menu style flick past the hub commits by direction, so once you've memorized your layout it's basically instant.
- A real config window — live preview, color and emoji pickers, themes, and sliders, so you never hand-edit JSON unless you want to.
And it now ships as a single .exe — no Python required.
Try it (30 seconds)
- Grab
Nimbus.exefrom the latest release. - Run it (it's unsigned, so Windows SmartScreen will ask — More info → Run anyway).
- Hold the right mouse button anywhere. That's it.
Right-click the tray icon and tick Start with Windows, and the wheel is always a long-press away, from every login.
Project structure
main.py entry point (logging + single-instance guard)
build.py PyInstaller build helper
nimbus/
app.py tray icon, hook/wheel wiring, watchdog
wheel.py the radial overlay (painting, interaction, search)
hook.py global low-level mouse hook (WH_MOUSE_LL)
actions.py launching apps / urls / folders / commands / window ops
icons.py app icons + website favicons (cached)
winicon.py real Windows shell icons (HICON -> QPixmap)
winmgr.py window management + foreground-window capture
config.py config load/save, defaults, themes, migration
config_ui.py the configuration editor window
autostart.py start-with-Windows registry toggle
single_instance.py named-mutex guard
logging_setup.py rotating file logging
If you want to see problem #1 in full, the hook state machine lives in nimbus/hook.py.
Get it / build it
-
Just want to use it? Download
Nimbus.exefrom the releases page, run it, and hold right-click. No Python, no setup. - Want to read or build the code? It's all on GitHub — MIT licensed.
If you build something on top of it, break it, or spot a smarter way to do any of this, open an issue or a PR — I'd genuinely like to hear it. And if it saves you a few trips to the Start menu, a ⭐ helps other people find it.

Top comments (0)