search.zig

18.5 KB · raw

1// search.zig — inline 18-line TUI widget, no alternate screen2// Raw terminal mode for interactive key handling. Linux-only (NixOS target).3const std = @import("std");4const builtin = @import("builtin");5const types = @import("types.zig");6const output = @import("output.zig");7const api = @import("api.zig");89const WIDGET_LINES: usize = 18;10const RESULT_ROWS: usize = 5;11const STDIN_FD = std.posix.STDIN_FILENO;1213// Debounce window: a keystroke defers the nix search until typing pauses this14// long. nix search is synchronous and blocks the widget for ~1s, so the window15// must be long enough that a normal between-keystroke pause doesn't trigger it —16// it should only fire once you've actually stopped typing.17const DEBOUNCE_MS: i64 = 500;1819// Milliseconds elapsed since `since` on the monotonic (awake) clock. std has no20// milliTimestamp in 0.16; this mirrors the Io.Clock pattern used in apply().21fn elapsedMs(io: std.Io, since: std.Io.Timestamp) i64 {22    const ns = @max(0, since.untilNow(io, .awake).toNanoseconds());23    return @intCast(@divFloor(ns, 1_000_000));24}2526// What the detail pane shows for the selected package. Tab toggles between the27// collapsed (description) view and the expanded meta view; the latter is fetched28// on demand, so it passes through loading and (on eval failure) unavailable.29const Detail = union(enum) {30    collapsed,31    loading,32    unavailable,33    meta: api.PkgMeta,34};3536pub fn run(37    gpa: std.mem.Allocator,38    io: std.Io,39    initial_query: []const u8,40    mode: Mode,41) !?types.SearchResult {42    // Non-interactive fallback: when stdin isn't a terminal (piped, scripted,43    // CI), print plain results and return instead of the raw-mode widget, which44    // needs a real tty and would block waiting for keys.45    if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode);4647    var saved: std.posix.termios = undefined;48    try rawModeEnable(STDIN_FD, &saved);49    defer rawModeDisable(STDIN_FD, saved);5051    var arena = std.heap.ArenaAllocator.init(gpa);52    defer arena.deinit();5354    var query_buf: [256]u8 = undefined;55    var query_len: usize = @min(initial_query.len, query_buf.len - 1);56    @memcpy(query_buf[0..query_len], initial_query[0..query_len]);5758    var packages: []types.NixPackage = &.{};59    var opts: []api.NixOption = &.{};60    var selection: usize = 0;6162    // Expanded detail (Tab): the selected package's meta, fetched on demand into63    // its own arena and cached by attr so re-visiting is instant. Kept separate64    // from the search arena, which is reset on every new search.65    var expanded = false;66    var meta_arena = std.heap.ArenaAllocator.init(gpa);67    defer meta_arena.deinit();68    var meta: ?api.PkgMeta = null;69    var meta_attr: []const u8 = "";7071    // Initial search72    _ = arena.reset(.free_all);73    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);7475    // Initial render (prints WIDGET_LINES lines)76    renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .collapsed);77    output.flush();7879    // Key loop with debounced search. A keystroke marks the query dirty and80    // stamps the time; the nix search fires only once typing pauses for81    // DEBOUNCE_MS. Stale results stay on screen until the new search returns, so82    // there is no blank flash mid-typing.83    var chosen: ?types.SearchResult = null;84    var query_dirty = false;85    var query_changed_at = std.Io.Clock.awake.now(io);86    loop: while (true) {87        // Block until a key arrives, unless a search is pending — then wake when88        // the debounce window closes so we can fire it.89        var timeout_ms: i32 = -1;90        if (query_dirty) {91            const remaining = DEBOUNCE_MS - elapsedMs(io, query_changed_at);92            timeout_ms = if (remaining <= 0) 0 else @intCast(remaining);93        }9495        var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};96        const ready = std.posix.poll(&pfd, timeout_ms) catch 0;9798        if (ready == 0) {99            // Poll timed out. If no search is pending, nothing changed — keep waiting.100            if (!(query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS)) continue;101            query_dirty = false;102            // Paint "searching..." over the stale results before the blocking nix103            // search, so the ~1s pause reads as progress, not a freeze.104            output.cursorUp(WIDGET_LINES);105            renderWidget(query_buf[0..query_len], packages, opts, selection, mode, true, .collapsed);106            output.flush();107108            _ = arena.reset(.free_all);109            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);110            // New result set: reset to the top and drop cached meta (it belonged to111            // the previous results). Navigating during the window could otherwise112            // leave selection past the new end and renderWidget would index OOB.113            selection = 0;114            meta = null;115            // fall through to the common render below116        } else {117            var buf: [4]u8 = undefined;118            const n = std.posix.read(STDIN_FD, &buf) catch break;119            if (n == 0) break;120121            if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {122                switch (buf[2]) {123                    'A' => if (selection > 0) { selection -= 1; }, // up124                    'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down125                    else => {},126                }127            } else switch (buf[0]) {128                'k' => if (selection > 0) { selection -= 1; },129                'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },130                0x1b, 'q' => break :loop,131                '\t' => expanded = !expanded, // Tab: toggle package detail132                'i', '\r' => {133                    if (mode == .packages and packages.len > selection) {134                        chosen = .{ .package = packages[selection], .action = .profile };135                    }136                    break :loop;137                },138                's' => {139                    if (mode == .packages and packages.len > selection) {140                        chosen = .{ .package = packages[selection], .action = .system };141                    }142                    break :loop;143                },144                't' => {145                    if (mode == .packages and packages.len > selection) {146                        chosen = .{ .package = packages[selection], .action = .profile };147                        // flag: try mode — caller handles148                    }149                    break :loop;150                },151                'c' => {152                    // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)153                    if (mode == .packages and packages.len > selection) {154                        copyToClipboard(gpa, io, packages[selection].attr);155                    }156                },157                127 => { // backspace158                    if (query_len > 0) {159                        query_len -= 1;160                        query_dirty = true;161                        query_changed_at = std.Io.Clock.awake.now(io);162                        selection = 0;163                    }164                },165                else => |ch| {166                    if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {167                        query_buf[query_len] = ch;168                        query_len += 1;169                        query_dirty = true;170                        query_changed_at = std.Io.Clock.awake.now(io);171                        selection = 0;172                    }173                },174            }175        }176177        // Common render after a search or a key. When expanded, resolve the detail178        // pane by fetching the selected package's meta on demand (blocking eval),179        // painting a "loading" frame first and caching by attr so re-visiting is180        // instant.181        const count = resultCount(packages, opts, mode);182        const sel = if (count == 0) 0 else @min(selection, count - 1);183        var detail: Detail = .collapsed;184        if (expanded and mode == .packages and count > 0) {185            const cur = packages[sel].attr;186            if (meta == null or !std.mem.eql(u8, meta_attr, cur)) {187                output.cursorUp(WIDGET_LINES);188                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .loading);189                output.flush();190                _ = meta_arena.reset(.free_all);191                meta_attr = meta_arena.allocator().dupe(u8, cur) catch "";192                meta = api.packageMeta(meta_arena.allocator(), io, cur);193            }194            detail = if (meta) |mm| Detail{ .meta = mm } else Detail.unavailable;195        }196        output.cursorUp(WIDGET_LINES);197        renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, detail);198        output.flush();199    }200201    // Clear the widget area202    output.cursorUp(WIDGET_LINES);203    var i: usize = 0;204    while (i < WIDGET_LINES) : (i += 1) {205        output.raw(output.C_CLEAR_EOL ++ "\n");206    }207    output.cursorUp(WIDGET_LINES);208    output.flush();209210    return chosen;211}212213pub const Mode = enum { packages, options };214215fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {216    // An empty query matches the entire catalog (100k+ rows) — a multi-second217    // fetch that would freeze the widget. Show nothing until there's a query.218    if (std.mem.trim(u8, query, " \t").len == 0) {219        opts.* = &.{};220        return &.{};221    }222    switch (mode) {223        .packages => return api.searchPackages(arena, io, query) catch &.{},224        .options => {225            opts.* = api.searchOptions(arena, io, query) catch &.{};226            return &.{};227        },228    }229}230231fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize {232    return switch (mode) {233        .packages => packages.len,234        .options => opts.len,235    };236}237238fn stdinIsTty(io: std.Io) bool {239    return std.Io.File.stdin().isTty(io) catch false;240}241242// Plain, non-interactive rendering for non-tty stdin. Same pink :: header and243// result count the widget shows, then one line per result; no cursor control.244fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode) !?types.SearchResult {245    // An empty query matches the entire catalog (100k+ rows); refuse it cleanly.246    if (std.mem.trim(u8, query, " \t").len == 0) {247        output.searchEmptyQuery();248        output.flush();249        return null;250    }251    var arena = std.heap.ArenaAllocator.init(gpa);252    defer arena.deinit();253    var opts: []api.NixOption = &.{};254    const packages = doSearch(arena.allocator(), io, query, mode, &opts);255    const count = resultCount(packages, opts, mode);256    const mode_str = if (mode == .packages) "nixpkgs" else "options";257    output.searchPlainHeader(mode_str, query, count);258    if (count == 0) {259        output.searchPlainNoResults();260        output.flush();261        return null;262    }263    switch (mode) {264        // unfree=false: per-result meta.unfree would need an eval per package (too265        // slow for the list); the TUI detail pane is the place to surface it.266        .packages => for (packages) |pkg| output.searchPlainPackage(pkg.pname, pkg.version, pkg.description, false),267        .options => for (opts) |opt| output.searchPlainOption(opt.name, opt.type_str),268    }269    output.flush();270    return null;271}272273// A key hint with dim brackets and a pink letter: dim '[', pink label, dim ']'.274// Comptime-built so it's a plain string literal — the widget always renders raw275// ANSI (it only runs on a real tty).276fn keyHint(comptime label: []const u8) []const u8 {277    return output.C_DIM ++ "[" ++ output.C_RESET ++ output.C_PINK ++ label ++ output.C_RESET ++ output.C_DIM ++ "]" ++ output.C_RESET;278}279280fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode, searching: bool, detail: Detail) void {281    const count = resultCount(packages, opts, mode);282    const CLR = output.C_CLEAR_EOL;283    const RST = output.C_RESET;284    const PKN = output.C_PINK;285    const BLD = output.C_BOLD;286    const DIM = output.C_DIM;287    const CYN = output.C_CYAN;288289    // Clamp to the current result count: a caller could hand us a selection from a290    // larger, now-replaced result set. Never index past the end.291    const sel = if (count == 0) 0 else @min(selection, count - 1);292293    // Line 1: header. While the (blocking) search runs, show "searching..." so the294    // pause reads as work-in-progress, not a frozen widget.295    output.raw(CLR);296    const mode_str = if (mode == .packages) "nixpkgs" else "options";297    if (searching) {298        output.p("{s}:: {s}search {s} {s}  {s}searching...{s}\n", .{299            PKN, RST, mode_str, query, DIM, RST,300        });301    } else {302        output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{303            PKN, RST, mode_str, query, DIM, count, RST,304        });305    }306307    // Line 2: blank308    output.raw(CLR ++ "\n");309310    // Lines 3-7: results (up to RESULT_ROWS), scrolled so selection stays visible311    const scroll: usize = if (sel >= RESULT_ROWS) sel - RESULT_ROWS + 1 else 0;312    var i: usize = 0;313    while (i < RESULT_ROWS) : (i += 1) {314        const idx = scroll + i;315        output.raw(CLR);316        if (idx < count) {317            const selected = (idx == sel);318            switch (mode) {319                .packages => {320                    const pkg = packages[idx];321                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";322                    const name_pre = if (selected) PKN ++ BLD else "";323                    const name_suf = if (selected) RST else "";324                    output.p("   {s}{s}{s}{s}   {s}{s}{s}   {s}\n", .{ marker, name_pre, pkg.pname, name_suf, CYN, pkg.version, RST, pkg.description });325                },326                .options => {327                    const opt = opts[idx];328                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";329                    output.p("   {s}{s}   {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST });330                },331            }332        } else {333            output.raw("\n");334        }335    }336337    // Line 8: blank338    output.raw(CLR ++ "\n");339340    // Lines 9-12: selected package detail. Line 9 (name+version) is shared; the341    // next three lines depend on `detail`. Every arm emits exactly 3 lines so the342    // widget stays WIDGET_LINES tall.343    if (count > 0 and mode == .packages) {344        const pkg = packages[sel];345        output.raw(CLR);346        output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, CYN, pkg.version, RST });347        switch (detail) {348            .collapsed => {349                output.raw(CLR);350                output.p("   {s}\n", .{pkg.description});351                output.raw(CLR ++ "\n");352                output.raw(CLR);353                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });354            },355            .loading => {356                output.raw(CLR);357                output.p("   {s}loading details...{s}\n", .{ DIM, RST });358                output.raw(CLR ++ "\n");359                output.raw(CLR);360                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });361            },362            .unavailable => {363                output.raw(CLR);364                output.p("   {s}details unavailable{s}\n", .{ DIM, RST });365                output.raw(CLR ++ "\n");366                output.raw(CLR);367                output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });368            },369            .meta => |m| {370                const home = if (m.homepage.len > 0) m.homepage else "—";371                const lic = if (m.license.len > 0) m.license else "—";372                output.raw(CLR);373                output.p("   {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "home", RST, CYN, home, RST });374                output.raw(CLR);375                output.p("   {s}{s:<8}{s}{s}\n", .{ DIM, "license", RST, lic });376                output.raw(CLR);377                output.p("   {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "attr", RST, CYN, pkg.attr, RST });378            },379        }380    } else if (count > 0 and mode == .options) {381        const opt = opts[sel];382        output.raw(CLR);383        output.p("   {s}  {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST });384        output.raw(CLR);385        output.p("   {s}\n", .{opt.description});386        output.raw(CLR ++ "\n");387        output.raw(CLR ++ "\n");388    } else {389        output.raw(CLR ++ "\n");390        output.raw(CLR ++ "\n");391        output.raw(CLR ++ "\n");392        output.raw(CLR ++ "\n");393    }394395    // Line 13: blank396    output.raw(CLR ++ "\n");397398    // Lines 14-17: controls + padding to reach WIDGET_LINES399    output.raw(CLR);400    if (mode == .packages) {401        output.p("   {s} profile  {s} system  {s} try  {s} copy  {s} info  {s} exit\n", .{402            keyHint("i"), keyHint("s"), keyHint("t"), keyHint("c"), keyHint("tab"), keyHint("esc"),403        });404    } else {405        output.p("   {s} insert  {s}[up/down]{s} nav  {s} exit\n", .{ keyHint("i"), DIM, RST, keyHint("esc") });406    }407    // Padding to reach WIDGET_LINES (18)408    var pad: usize = 14;409    while (pad < WIDGET_LINES) : (pad += 1) {410        output.raw(CLR ++ "\n");411    }412}413414fn copyToClipboard(gpa: std.mem.Allocator, io: std.Io, text: []const u8) void {415    _ = gpa;416    // Try common clipboard tools417    const tools = [_][]const u8{ "pbcopy", "xclip", "xsel" };418    for (tools) |tool| {419        var child = std.process.spawn(io, .{420            .argv = &.{tool},421            .stdin = .pipe,422            .stdout = .ignore,423            .stderr = .ignore,424        }) catch continue;425        if (child.stdin) |stdin| {426            stdin.writeStreamingAll(io, text) catch {};427            stdin.close(io);428        }429        _ = child.wait(io) catch {};430        return;431    }432}433434fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {435    var tio = try std.posix.tcgetattr(fd);436    saved.* = tio;437    if (comptime builtin.os.tag == .linux) {438        tio.lflag.ECHO = false;439        tio.lflag.ICANON = false;440        tio.lflag.ISIG = false;441        tio.lflag.IEXTEN = false;442        tio.iflag.ICRNL = false;443        tio.iflag.IXON = false;444        tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;445        tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;446    }447    // On non-Linux, terminal will not be in raw mode (TUI requires Linux)448    try std.posix.tcsetattr(fd, .FLUSH, tio);449}450451fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {452    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};453}