1
0
Fork
You've already forked zpicker
0
Small UI-agnostic state machine for fuzzy-filtered list pickers.
  • Zig 100%
Mikael Säker 22a36a8e8f docs: rename zsift -> sift in comments
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026年06月27日 10:28:54 +02:00
src docs: rename zsift -> sift in comments 2026年06月27日 10:28:54 +02:00
.gitignore Ignore and untrack zig-pkg/ 2026年05月11日 09:37:58 +02:00
build.zig Drop zmatch dep — matcher is plugged in by the caller 2026年05月13日 15:29:46 +02:00
build.zig.zon Drop zmatch dep — matcher is plugged in by the caller 2026年05月13日 15:29:46 +02:00
README.md Drop zmatch dep — matcher is plugged in by the caller 2026年05月13日 15:29:46 +02:00

zpicker

A small UI-agnostic state machine for filtered list pickers — the kind used for command palettes, file pickers, buffer switchers, and similar "type to narrow, arrow to choose" widgets. Zero dependencies — the caller plugs in their own scoring function, so any matcher (fuzzy, prefix, substring, your own) works. Intended to be embedded in editors and tools that handle their own rendering.

The package owns:

  • Query buffer with codepoint-safe appendQuery / backspaceQuery / setQuery / clearQuery for tail-only editing, plus insertAt / deleteForward / moveCursor* for mid-string editing (command palettes, search bars).
  • Filtered result list with scores and per-item match positions (byte offsets, suitable for highlight rendering).
  • Selection cursor and scroll offset with the usual moveUp / moveDown / pageUp / pageDown / moveHome / moveEnd.
  • setItems to swap in a new borrowed item slice and re-filter.
  • refilterWith(ctx, scoreFn) — caller-supplied scoring for cases where fuzzy alone isn't enough (e.g. alias-bonus scoring in a command palette where an exact alias should outrank a fuzzy match on the long command name).

The package deliberately does not own:

  • Rendering — the caller draws the query, list rows, scroll bar, and any preview pane.
  • Item sources — the caller passes a []const []const u8 slice; the picker borrows it.
  • Actions — pressing Enter, mouse clicks, and focus handling are caller concerns. Use selectedEntry / selectedItem to read state.

Quick start

constzpicker=@import("zpicker");varpicker=tryzpicker.Picker.init(allocator,.{});deferpicker.deinit();// Plug in a scorer. The context is opaque to zpicker — typically a// pointer to your matcher or any state it needs to score items.picker.setScorer(&my_matcher,fuzzyScore);picker.setItems(items);// borrowed; outlives the picker or replace via setItems_=picker.appendQuery("foo");picker.moveDown(visible_rows);if(picker.selectedItem())|item|{// Enter: open `item`, etc.}fnfuzzyScore(ctx:?*anyopaque,_:usize,item:[]constu8,query:[]constu8)?u16{constmatcher:*MyMatcher=@ptrCast(@alignCast(ctx.?));returnmatcher.match(item,query);}

Without a registered scorer, an empty query passes all items through in source order and a non-empty query produces an empty filtered list.

Mid-string editing

For command palettes and search inputs that need cursor movement and insertion at arbitrary positions:

_=picker.insertAt("text");// insert at cursor_pos, advance cursorpicker.moveCursorLeft();// step left by one codepointpicker.moveCursorRight();// step right by one codepointpicker.moveCursorHome();// Ctrl-Apicker.moveCursorEnd();// Ctrl-Epicker.backspaceQuery();// delete codepoint before cursorpicker.deleteForward();// delete codepoint at cursor (Del key)

appendQuery keeps the cursor at the end; mix the two freely.

One-shot custom scoring

refilterWith is a comptime-typed variant for cases where you want to score against something other than the picker's query (e.g. a file-completion path with a prefix extracted from the full query):

constScorer=struct{fnscore(commands:[]constCommand,index:usize,item:[]constu8,// = commands[index].namequery:[]constu8,)?u16{constcmd=commands[index];for(cmd.aliases)|a|{if(std.mem.eql(u8,a,query))return10000;if(a.len>query.lenandstd.mem.eql(u8,a[0..query.len],query))return5000;}returnmy_matcher.match(cmd.name,query);}};picker.refilterWith(commands,Scorer.score);

The context is anytype, so any struct, slice, or scalar works. The registered default scorer (from setScorer) is not replaced; it takes back over on the next state change.

Match positions

zpicker doesn't store match positions in FilteredEntry — at 100K items the difference is 1 MB vs. 28 MB of memory. Recompute them at render time for just the visible rows (typically ~50) by calling your matcher's matchIndices (or equivalent) for each row about to be drawn.

Config

zpicker.Picker.init(allocator,.{.max_query_bytes=128,// fixed-size query buffer.max_items=4096,// filtered list cap});

License

MIT.