Building an App Launcher Widget for Zebar
I wanted a waffle-menu style app launcher in my Zebar bar. Click a button, get a dropdown of favorite apps. Simple concept. The implementation taught me more about Windows shell escaping and Tauri window behavior than I expected.
Built the whole thing with Claude Code as a pair programmer. Published to the Zebar Marketplace.
The Architecture Surprise
My first misunderstanding: I assumed I could embed a dropdown inside the bar widget’s DOM. Nope. Zebar widgets are separate Tauri windows, not DOM elements. Each widget is its own OS window. So the app launcher had to be a standalone window that visually overlaps the bar.
This means zOrder: "top_most" and transparent: true in the widget config. The transparent background makes the window invisible except for the button and dropdown. Sounds fine, until you realize…
The Click-Through Problem
A transparent Tauri window still captures all mouse events across its entire rectangle. My 200x320px launcher window was blocking clicks to the bar beneath it. Workspace buttons, the terminal shortcut, everything under the transparent area was dead.
What Didn’t Work
CSS pointer-events: none only affects the HTML layer. The OS window still captures clicks.
setIgnoreCursorEvents(true) makes the entire window click-through, including the launcher button itself. Once enabled, no mouse events fire at all, so you can’t even toggle it back on hover. The button just disappears.
What Worked: Dynamic Window Resizing
Keep the window tiny (button-sized) when the dropdown is closed. Expand it when opened:
import { getCurrentWindow } from 'https://esm.sh/@tauri-apps/api@2.0.2/window';
import { LogicalSize } from 'https://esm.sh/@tauri-apps/api@2.0.2/dpi';
const tauriWindow = getCurrentWindow();
// Closed: just the button
await tauriWindow.setSize(new LogicalSize(48, 40));
// Open: button + dropdown
await tauriWindow.setSize(new LogicalSize(170, 240));
This way the window only occupies the space it actually needs. No dead zones blocking the bar.
The setSize Gotcha
This took multiple attempts to get right. Tauri’s setSize needs a proper LogicalSize class instance. Plain objects like { type: 'Logical', width: 170, height: 240 } don’t deserialize on the Rust side. You also need to import @tauri-apps/api directly from esm.sh, not through the zebar package re-export, which doesn’t wire up setSize correctly.
Windows Shell Escaping Hell
Launching apps via zebar.shellExec() was the other rabbit hole. Under the hood, Zebar’s shell exec has two code paths in Rust:
- String argument: naively split on spaces (
args.split(' ')), which breaks any path with spaces, even if quoted - Array argument: passed to
std::process::Command::args(), where Rust re-escapes each element using MSVCRT rules
The classic Windows cmd /c start pattern needs "" as a dummy window title when the target path is quoted. But passing "" as an array element gets MSVCRT-escaped by Rust into \"\", which cmd.exe interprets as a literal backslash path \\.
The Fix
Drop the "" entirely and pass simple array args:
await zebar.shellExec('cmd', ['/c', 'start', command]);
This works for exe names in PATH (wt.exe, notepad.exe, code). Each array element is simple with no special characters, so Rust passes them through unmangled. Not a universal solution for paths with spaces, but covers the common case.
Two Layout Modes
The widget ships with two presets:
- Floating: standalone button positioned below the bar with rounded corners and a border
- Embedded: flush with the bar’s top-left corner, no border, blends in as if it’s part of the bar
The embedded mode needed some finessing. The button has to match the bar’s height exactly, padding has to be zeroed out so it sits flush, and the hover highlight needs a slight border-radius to avoid clipping at the bar’s rounded edge.
Configuration
Everything lives in a single config.json: mode, theme colors, and the app list. Theme colors are applied at runtime via CSS custom properties with sensible defaults as fallback, so the widget works even if config loading fails.
{
"mode": "floating",
"theme": {
"background": "rgba(13 14 22 / 92%)",
"accent": "#7aa2f7"
},
"apps": [
{ "name": "Terminal", "icon": "nf nf-md-console", "command": "wt.exe" }
]
}
Icons use Nerd Fonts loaded via CSS import. Each app entry is just a name, icon class, and command.
What I Learned
The actual widget UI was the easy part. The hard parts were all about the gap between web development assumptions and OS-level window behavior: transparent windows that aren’t really transparent to input, shell escaping that fights you at every layer, and Tauri APIs that need exactly the right import path and class types.
Claude Code handled the iteration loop well here. Each failed approach (CSS pointer-events, setIgnoreCursorEvents, plain object sizes) was quickly identified and replaced. The back-and-forth of “try thing, see it fail, understand why, try next thing” is where AI pair programming works best. It keeps the momentum going when you’d otherwise be stuck reading docs.