Products

Emoji & Symbol Picker

A Raycast-inspired emoji and Unicode symbol picker for the Noctalia Wayland shell: ranked search over ~4,000 entries on every keystroke, inside the 25ms per-callback CPU budget Noctalia gives every plugin.

Active · 2026

Luau · Noctalia Plugin API · Unicode CLDR

Emoji & Symbol Picker

A Raycast-inspired emoji and Unicode symbol picker for the Noctalia Wayland shell. One Luau script, three panel sizes, and ranked search over ~4,000 entries on every keystroke.

The flow is the whole point. Press a bind, type “coffee” or “arrow right” or paste a character itself, hit Enter. The glyph lands on your clipboard and the panel is already gone. Everything else on this page exists to keep that under a tenth of a second.

The interesting constraint is that Noctalia runs plugins inside a VM with a hard CPU budget. Every callback gets 25 milliseconds of thread time, and a script that blows it gets its call cancelled. A picker that stutters on open or lags a keystroke is a picker you stop binding, so the budget shaped nearly every design decision here.

What you get

One script serves three panel entries, wide, desktop and compact, so a bind can pick the size that suits the display it opens on. The grid derives its column count and glyph size from the focused output’s width. Nothing needs configuring.

  • Ranked search across the full dataset on every keystroke, no debouncing
  • Word-level relevance tiers, so “euro” ranks the € character above the 💶 banknote and “star” still puts ⭐ first
  • Skin-tone variants stay searchable but never clutter the browse grid
  • A frequently-used row learned from what you actually pick, with a two-week recency half-life so last month’s picks fade
  • Full keyboard control. Arrows, Enter to copy and close, Escape reserved by the host so the panel can never trap you
  • An optional paste command for people who run wtype or ydotool, because Wayland deliberately will not let one surface inject keystrokes into another

The dataset

assets/emoji.json holds 4,071 entries generated from pinned Unicode sources, CLDR 47 English annotations plus Unicode 16.0 emoji-test.txt and UnicodeData.txt. A Python generator does the slow, fussy work once at build time. It lower-cases names and keywords, dedupes keywords against names, and emits the whole thing as one compact array. The picker then derives each entry’s search blob during indexing, which keeps the file a third smaller than shipping pre-built blobs.

Names are the primary identity and keywords are secondary aliases, and the ranking encodes that. An entry’s score for a query is its weakest word match, all words must match, and ties break on how specific the name is. “Red heart” outranks “smiling face with hearts” for the query “heart” because a short, focused name is more centrally about the word you typed.

Living inside the budget

The panel opens in a few milliseconds and searches the full dataset in well under one. That is not an accident, it is the residue of several budget-related failures I got to watch happen live. A naive version that allocated a token array per entry errored out with “exceeded its CPU budget” on open. A version that scored the whole result set for a single typed “e” did the same on the first keystroke. Each one taught the search its current shape: one plain substring scan as a pre-filter, bounded result sets, and ranking work only for entries that already matched.

The nastiest bug the budget produced shipped in 1.0.0. On slower or power-throttled machines, decoding the 1.1MB dataset inside the first open callback crossed the 25ms line. The host interrupts the script at the next VM instruction, that error landed inside the pcall wrapping the decode, and the pcall swallowed it. The picker then cached “dataset loaded, zero entries” as a success and showed “No results” until the shell restarted. Three users hit it before I understood what was happening. The fix moved the load to script-load time, which gets four times the budget, made every load failure retry on the next open instead of caching, cut the file by a third, and gave failures a visible error state with the actual reason. Debugging it meant reading the shell’s interrupt code to find out that an error raised at an instruction boundary gets caught by any pcall in the way, which is now my favourite piece of trivia about Luau hosting.

Testing

The test suite is a single generated Luau file that runs under the stock luau CLI with no host dependencies. It embeds the real dataset and the real picker source, stubs the noctalia API, and checks 103 things. Search ranking cases like “plusminus” → ±, literal character queries, the keyboard model, the commit and clipboard path, cold-load timing against the budget, and the load-failure semantics from that 1.0.0 bug, including a simulated budget interrupt that must leave the picker unloaded rather than silently empty. If a plausible bug would not fail one of those checks, the check probably should not exist.

Open chat

Interested in working together? Reach out.

Strategy, architecture, and implementation — from workflow to production.

© 2026 Liam Woodleigh. All rights reserved.