- Python 96.6%
- Shell 2.2%
- JavaScript 1.2%
Plain RGB distance mapped dark greys to dim blue/green (#080808 -> 43), so a focused RGB-grey pad pulsed blue. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|---|---|---|
| docs | ||
| launchpad_taskbar | ||
| packaging | ||
| scripts | ||
| tests | ||
| .gitignore | ||
| .python-version | ||
| CLAUDE.md | ||
| config.example.toml | ||
| LICENSE | ||
| PLAN.md | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
launchpad-controller
Mirror your desktop taskbar onto a Novation Launchpad Pro MK1.
Not affiliated with, endorsed by or sponsored by Novation or Focusrite. "Launchpad" is their trademark.
Mirrors your desktop taskbar onto a Novation Launchpad Pro MK1: one pad per open window of your favourite apps, press to focus or launch, double tap to minimise, hold to close. A Python process talks to the Launchpad over USB MIDI (Programmer layout) and to the window manager through a platform backend.
| Platform | Status |
|---|---|
| KDE Plasma 6 (Wayland) | Working |
| macOS | Working (windows on other Spaces aren't visible) |
Features
- One pad per open window of the apps you list, in the order you list them; the focused window pulses.
- Press to focus, double tap to minimise, hold to close (with a charge-up meter), press a dull pad to launch the app.
- Idle mode turns the LEDs off to save wear, with a slow pulse on the side LED.
- Survives unplugging or switching the Launchpad; it reconnects by itself.
- Runs as a systemd user service (Linux) or a launchd agent (macOS).
How it looks and works
The bottom row of square pads (LED ids 11-18) is the taskbar. Apps appear in the order they are listed in config.toml.
round buttons right column (long-press meter, bottom to top)
( ) ( ) ( ) ( ) ( ) ( ) ( ) ( ) ( ) <- top row, unused
[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] (o) CC 89 meter: top = close
[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] (o) ...
[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] (o) CC 19 meter: bottom = first to light
[F] [F] [T] [T] [S] [Z] [z] [ ] <- taskbar row (pads 11-18)
| | | | | | |
| | | | | | +-- Zed, not running: dull pad = launcher
| | +---+ | +------ Zed, one window: bright
| | +---------- Steam window: bright
+---+---------------------- two Firefox windows: two bright pads
- One bright pad per open window. Minimised windows stay bright.
- The focused window's pad pulses.
- An app with no windows shows one dull pad, which launches it.
- Windows keep their first-seen order, so pads don't reshuffle when focus changes. Windows on all virtual desktops are shown.
| Input | Action |
|---|---|
| Press a bright pad | Focus and raise the window at once (restores it if minimised, switches desktop) |
| Double tap a bright pad | Minimise it |
| Hold a bright pad (long press) | Charge-up meter on the right column of round buttons; when it is full the window closes |
| Press a dull pad | Launch the app |
Press, double tap and launch (see PLAN.md D23-D26). Pressing a window pad focuses it immediately, so you can see which window you are about to close. A second press on the same window within double_tap_ms (default 300 ms) of a first press that was released before the meter started minimises it instead; that second press does not start a hold, and a third quick press focuses again rather than minimising twice. A dull launcher pad launches on press, and a repeat press within double_tap_ms is ignored.
Long press (see PLAN.md D18, D19, D21, D25). Keep holding a window pad and, after delay_ms (default 300 ms), the meter fills bottom to top, green to yellow to red by default: the first LED lights at delay_ms, the eighth one step ((duration_ms - delay_ms) / 8) before the end, and the window closes at duration_ms (1.5 s after the press), so the full column is visible for one step. Close is a normal close (like clicking the window's X, so the app can still ask to save), never a process kill. Releasing early cancels the close and clears the meter; nothing else happens, since the focus already happened on press. The held pad flashes while charging. Holding a dull launcher pad does nothing more, and only one hold is tracked at a time. With long press disabled, pads still focus on press, double tap still minimises, and nothing closes. Browser tabs can't be closed this way, only windows.
Idle mode (see PLAN.md D30-D35). After timeout_min (default 10) with no activity, every pad and button goes dark to save LED wear. Activity is a Launchpad button press, a change of focused window, or a window opening or closing (title changes don't count). Locking the screen goes idle at once; unlocking wakes. The side LED then pulses slowly for breathe_min minutes (the hardware pulse, slowed by sending MIDI clock at pulse_bpm), and afterwards lights solid for heartbeat_on_s (default 1 s) every heartbeat_s (default 10 s). Any press, focus/window change or unlock wakes the display and redraws it; the waking press and its release are swallowed, so nothing is focused or launched blindly. On wake the pulse tempo is put back to 120 BPM, which the focused pad's pulse and the flashing held pad share.
At most 8 pads are shown for now. Scrolling is deferred until needed.
Requirements
- Novation Launchpad Pro MK1 (not the MK3), connected over USB. The app uses the "Launchpad Pro Standalone Port" and puts it in Programmer layout.
- uv.
- Python 3.12 (pinned in
.python-version; uv fetches it).python-rtmidihas no wheels for 3.13+, and building it from source needsalsa-lib-devel. - Supported platforms:
- Linux with KDE Plasma 6 on Wayland. The backend uses KWin scripting over D-Bus. On immutable distros like Fedora Atomic nothing extra needs installing, since the wheels cover everything.
- macOS. The backend uses the Accessibility API through pyobjc, so it needs the Accessibility permission (System Settings → Privacy & Security → Accessibility): for your terminal app when run by hand, and for the Python binary when run as a launchd agent. The app asks on first start. Only windows on the main display (the one with the menu bar) get pads; windows on other monitors are ignored. Only windows on the current Space are visible (an app whose windows are all on other Spaces shows a dull pad; pressing it switches to the app).
Quick start
git clone https://github.com/kartikay-bagla/launchpad-controller.git
cd launchpad-controller
uv sync
cp config.example.toml config.toml # config.toml is gitignored
$EDITOR config.toml
uv run python -m launchpad_taskbar -v
Options: --config PATH (default ./config.toml; the app exits with an error if the file is missing), -v for debug logging, --idle-after SECONDS (dev). Ctrl+C (or SIGTERM) stops it cleanly: the LEDs are cleared and the KWin script is unloaded.
Configuration
Everything lives in config.toml, which you create by copying config.example.toml (it is gitignored, so your edits stay local). The example is the full, commented schema.
[[apps]]
name = "zed"
colour = "purple"
kde = { match = ["dev.zed.Zed", "zed"], launch = ["gtk-launch", "dev.zed.Zed"] }
macos = { match = ["dev.zed.Zed"], launch = ["open", "-b", "dev.zed.Zed"] }
-
[[apps]]order is the pad order, left to right.nameis required and must be unique. -
colouris a palette name:red,orange,yellow,lime,green,cyan,blue,purple,magenta,pink,white. Each maps to a bright/dull pair of Launchpad palette indices (dull = the very dim shade of the same hue). -
bright/dullare each an explicit Launchpad palette index (0-127) or a hex colour string such as"#101010"(case-insensitive,#required; the two may be mixed). Hex colours are drawn exactly on static pads, while pulse and flash (a pad that is pulsing or charging) use the nearest palette colour; a colour that rounds to black is rejected. Each overrides the matching half ofcolour; if you omitcolour, give both. Index values are indocs/launchpad-pro-mk1-midi-reference.md. -
kde/macosare per-platform tables. Only the table for the current platform is used; apps without one are skipped.match: app ids to match, case-insensitive. KDE: desktop file name / window resource class / resource name. macOS: bundle id. Required.launch: argv run when a dull pad is pressed. Optional. On KDE, run undersystemd-run --user --scopewhen available.
-
[monitors](optional, KDE) gives one pad row per monitor:kde = ["HDMI-A-1", "Virtual-ipad1"]lists KWin output names, bottom row first (1-8, unique). Each row shows only the windows on its monitor; launcher pads appear only on the bottom row, and windows on unlisted monitors are hidden. Without it, the bottom row shows every window. Find output names withkscreen-doctor -o.
Finding an app's id on KDE
Run the watcher and focus the app. It prints one line per window: F = focused, m = minimised, then window id, comma-separated app ids, and title.
uv run python scripts/kde_watch.py
Use any of the listed app ids in match. Stop stray instances first (see below), since only one process can own the D-Bus name.
Alternatively, run the app with uv run python -m launchpad_taskbar -v. It logs every window that matches no configured app as unmatched window <id> app_ids=<ids> title=<title>, once per window, so you can read the ids from the log while the app runs.
Finding an app's id on macOS
match takes bundle ids. Print every app's windows (F = focused, m = minimised, then window id, bundle id and title):
uv run python scripts/macos_watch.py
Or osascript -e 'id of app "Zed"'. With -v, the app also logs each running app it isn't watching as unmatched app <bundle id> (<name>).
[long_press]
Documented from the schema in config.example.toml.
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Enable hold-to-close. When false, there is no meter and no close; press and double tap work as usual. |
delay_ms |
300 |
Hold time before the meter starts. A press released sooner can count towards a double tap. |
duration_ms |
1500 |
Time from press until the window closes (meter full). |
meter_colours |
[21, 21, 17, 13, 13, 9, 5, 5] |
8 palette indices for the right column of round buttons, bottom to top. |
flash_held_pad |
true |
Flash the held pad while charging. |
[taps]
| Key | Default | Meaning |
|---|---|---|
double_tap_ms |
300 |
Window for a double tap (minimise) on a window pad, and for ignoring a repeat press on a launcher pad. Positive integer. |
[idle]
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Enable idle mode. |
timeout_min |
10 |
Minutes without activity before going idle. Positive number. |
breathe_min |
10 |
Minutes the side LED pulses continuously after going idle. |
heartbeat_s |
10 |
After that, one solid blip every this many seconds, dark in between. |
heartbeat_on_s |
1 |
How long each heartbeat blip stays lit (solid, not pulsing). Must be less than heartbeat_s. |
side_colour |
46 |
Palette index 0-127 of the side LED pulse (dim blue). |
pulse_bpm |
40 |
Pulse tempo, 40-240 (the device's range). |
lock_triggers_idle |
true |
Go idle immediately when the screen locks (org.freedesktop.ScreenSaver). |
For quick manual testing run with --idle-after SECONDS (overrides timeout_min), e.g. uv run python -m launchpad_taskbar --idle-after 10.
Run as a service
The same commands work on both platforms: a systemd user unit on Linux, a launchd agent on macOS.
macOS (launchd agent)
scripts/service.sh install runs uv sync, creates config.toml if missing, renders packaging/launchd/local.launchpad-taskbar.plist.in into ~/Library/LaunchAgents/local.launchpad-taskbar.plist and loads it; it starts at login. It prints the Python binary to allow under Accessibility; until you do, the agent exits and retries every 10 s. Logs go to ~/Library/Logs/launchpad-taskbar.log (scripts/service.sh logs). stop unloads it until the next login or start. Only one instance can run (~/Library/Caches/launchpad-taskbar.lock), so stop the agent before a dev run.
Linux (systemd user unit)
scripts/service.sh install # uv sync, render + enable + start the unit
scripts/service.sh status
scripts/service.sh logs # follow the journal
scripts/service.sh restart
scripts/service.sh uninstall
install runs uv sync, creates config.toml from config.example.toml if it doesn't exist, renders packaging/systemd/launchpad-taskbar.service.in with the absolute repo path into ~/.config/systemd/user/launchpad-taskbar.service (or $XDG_CONFIG_HOME), then systemctl --user enable --now on it. It is tied to graphical-session.target.
- The unit runs
<repo>/.venv/bin/python -m launchpad_taskbar --config <repo>/config.toml. If you move the repo, re-runinstall. - Unplugging the Launchpad, or switching your USB switch to another machine, is fine: the app notices within about 1 s, keeps tracking windows, and reconnects, re-inits the Programmer layout and redraws by itself within about 1-2 s of the device coming back. It also starts fine with no Launchpad attached and logs "waiting for Launchpad…".
Restart=on-failure, every 10 s, with no start limit: just a crash safety net. It isn't needed for unplugging.- Apps launched from pads run in their own systemd scope, so they survive service restarts.
- After changing code or
config.toml, runscripts/service.sh restart. - Stop the service before running a dev instance (
scripts/service.sh stop, orsystemctl --user stop launchpad-taskbar;scripts/service.sh startbrings it back up). The D-Bus namelocal.LaunchpadTaskbaris exclusive.
Development
launchpad_taskbar/
types.py contracts between core, driver and backends
core.py pure logic: windows -> pad states, pad -> press target (no I/O)
gestures.py pure press / hold / double-tap state machine (pad presses -> actions)
idle.py pure idle-mode state machine (timeout, lock, side LED pulse schedule)
config.py TOML loader, colour palette
launchpad.py Launchpad Pro MK1 driver (diffed SysEx LED output, button input)
backends/kde.py KDE backend (D-Bus service local.LaunchpadTaskbar)
backends/kwin_bridge.js KWin script that pushes window snapshots
backends/macos.py macOS backend (Accessibility API + NSWorkspace via pyobjc)
__main__.py wiring, signal handling, Launchpad reconnect supervisor
scripts/ service.sh, kde_watch.py, macos_watch.py, launchpad_demo.py
packaging/ systemd unit and launchd agent templates
- Tests:
uv run pytest(they readconfig.example.toml, not yourconfig.toml). PLAN.mdis the source of truth for decisions and feature status. Update it when a decision changes.docs/launchpad-pro-mk1-midi-reference.mdis the device protocol reference; read it before writing any MIDI code.- The official Novation Launchpad Pro Programmer's Reference Guide v1.01 (PDF) is the underlying source for that reference.
Troubleshooting
config.toml not found. Runcp config.example.toml config.tomland edit it.- Launchpad not found / not reconnecting. The app keeps running and logs "waiting for Launchpad…"; it connects as soon as the port appears (about 1 s poll). The driver looks up the port by name, "Launchpad Pro Standalone Port". Check that it is listed with
amidi -land that the device is the MK1. Run with-vto see failed reopen attempts. - D-Bus name already taken. Another instance or the service is running. Stop it (
scripts/service.sh stop, orsystemctl --user stop launchpad-taskbar). - KWin script left loaded after a hard kill. The next start unloads it. To do it by hand:
qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript launchpad-taskbar-bridge. - Layout keeps snapping back. While running, the app forces the Launchpad back to Programmer layout if you switch layout via Setup. Stop the app to use other layouts.
Status and roadmap
See PLAN.md for the full table.
- Built: core, Launchpad driver, KDE backend, macOS backend (NSWorkspace + Accessibility; AX can't see windows on other Spaces), systemd user service, launchd agent, long press to close with charge-up meter, idle mode (LEDs off, pulsing side LED).
- Deferred: scrolling past 8 pads (round buttons CC 10 / CC 19).
- Idea: a second pad row for workspaces / virtual desktops.
License
GPL-3.0-or-later. See LICENSE.
Credits
Device protocol details come from the Novation Launchpad Pro Programmer's Reference Guide v1.01 (official PDF) and were checked on real hardware. This project is not affiliated with Novation or Focusrite; "Launchpad" is their trademark.