No description
  • Python 96.6%
  • Shell 2.2%
  • JavaScript 1.2%
Find a file
kartikay-bagla dcea1d7d02 nearest_palette: weigh chroma over brightness (D43)
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>
2026-10-29 15:07:15 +05:30
docs Prepare for public release: GPL-3.0-or-later, config split, generic docs, drop vendor PDF 2026-10-22 18:02:56 +05:30
launchpad_taskbar nearest_palette: weigh chroma over brightness (D43) 2026-10-29 15:07:15 +05:30
packaging macOS backend and launchd agent (D38-D41) 2026-10-29 14:45:51 +05:30
scripts macOS backend and launchd agent (D38-D41) 2026-10-29 14:45:51 +05:30
tests nearest_palette: weigh chroma over brightness (D43) 2026-10-29 15:07:15 +05:30
.gitignore Prepare for public release: GPL-3.0-or-later, config split, generic docs, drop vendor PDF 2026-10-22 18:02:56 +05:30
.python-version Scaffold: MIDI reference, uv env, plan, shared contracts, config 2026-10-22 16:21:13 +05:30
CLAUDE.md macOS backend and launchd agent (D38-D41) 2026-10-29 14:45:51 +05:30
config.example.toml Merge custom RGB app colours (D43) 2026-10-29 15:04:16 +05:30
LICENSE Prepare for public release: GPL-3.0-or-later, config split, generic docs, drop vendor PDF 2026-10-22 18:02:56 +05:30
PLAN.md nearest_palette: weigh chroma over brightness (D43) 2026-10-29 15:07:15 +05:30
pyproject.toml Merge per-monitor pad rows (D42) 2026-10-29 14:54:27 +05:30
README.md Merge custom RGB app colours (D43) 2026-10-29 15:04:16 +05:30
uv.lock macOS backend and launchd agent (D38-D41) 2026-10-29 14:45:51 +05:30

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-rtmidi has no wheels for 3.13+, and building it from source needs alsa-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. name is required and must be unique.

  • colour is 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 / dull are 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 of colour; if you omit colour, give both. Index values are in docs/launchpad-pro-mk1-midi-reference.md.

  • kde / macos are 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 under systemd-run --user --scope when 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 with kscreen-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-run install.
  • 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, run scripts/service.sh restart.
  • Stop the service before running a dev instance (scripts/service.sh stop, or systemctl --user stop launchpad-taskbar; scripts/service.sh start brings it back up). The D-Bus name local.LaunchpadTaskbar is 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 read config.example.toml, not your config.toml).
  • PLAN.md is the source of truth for decisions and feature status. Update it when a decision changes.
  • docs/launchpad-pro-mk1-midi-reference.md is 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. Run cp config.example.toml config.toml and 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 -l and that the device is the MK1. Run with -v to see failed reopen attempts.
  • D-Bus name already taken. Another instance or the service is running. Stop it (scripts/service.sh stop, or systemctl --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.