DEV Community

Cover image for I Wanted a Better App Launcher for Windows, So I Built One
Darshan Rathod
Darshan Rathod

Posted on

I Wanted a Better App Launcher for Windows, So I Built One

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.

Nimbus demo

Repo: https://github.com/darshan-bytespec/nimbus

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 19) 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 the LLMHF_INJECTED flag, so my own hook ignores them and lets them flow through — and the context menu appears exactly as expected.

Two things bit me here:

  1. 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.
  2. 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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 global sys.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/argtypes on every call. 64-bit handle truncation is the number one silent bug.
  • Prefer painting over platform effects. A translucent window and QPainter beat 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 Enter launches 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)

  1. Grab Nimbus.exe from the latest release.
  2. Run it (it's unsigned, so Windows SmartScreen will ask — More info → Run anyway).
  3. 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
Enter fullscreen mode Exit fullscreen mode

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.exe from 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)