Small UI-agnostic state machine for fuzzy-filtered list pickers.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker cf548497cb Add MIT license
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:00:22 +02:00
src setItems: preserve the filtered selection for refilterWith callers 2026-07-21 15:35:16 +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
LICENSE Add MIT license 2026-07-25 12:00:22 +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

const zpicker = @import("zpicker");

var picker = try zpicker.Picker.init(allocator, .{});
defer picker.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.
}

fn fuzzyScore(ctx: ?*anyopaque, _: usize, item: []const u8, query: []const u8) ?u16 {
    const matcher: *MyMatcher = @ptrCast(@alignCast(ctx.?));
    return matcher.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 cursor
picker.moveCursorLeft();        // step left by one codepoint
picker.moveCursorRight();       // step right by one codepoint
picker.moveCursorHome();        // Ctrl-A
picker.moveCursorEnd();         // Ctrl-E
picker.backspaceQuery();        // delete codepoint before cursor
picker.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):

const Scorer = struct {
    fn score(
        commands: []const Command,
        index: usize,
        item: []const u8,     // = commands[index].name
        query: []const u8,
    ) ?u16 {
        const cmd = commands[index];
        for (cmd.aliases) |a| {
            if (std.mem.eql(u8, a, query)) return 10000;
            if (a.len > query.len and std.mem.eql(u8, a[0..query.len], query)) return 5000;
        }
        return my_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.