search.zig

14.1 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. Without it, every character fired a full `nix search`, stalling mid-word.15const DEBOUNCE_MS: i64 = 250;1617// Milliseconds elapsed since `since` on the monotonic (awake) clock. std has no18// milliTimestamp in 0.16; this mirrors the Io.Clock pattern used in apply().19fn elapsedMs(io: std.Io, since: std.Io.Timestamp) i64 {20    const ns = @max(0, since.untilNow(io, .awake).toNanoseconds());21    return @intCast(@divFloor(ns, 1_000_000));22}2324pub fn run(25    gpa: std.mem.Allocator,26    io: std.Io,27    initial_query: []const u8,28    mode: Mode,29) !?types.SearchResult {30    // Non-interactive fallback: when stdin isn't a terminal (piped, scripted,31    // CI), print plain results and return instead of the raw-mode widget, which32    // needs a real tty and would block waiting for keys.33    if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode);3435    var saved: std.posix.termios = undefined;36    try rawModeEnable(STDIN_FD, &saved);37    defer rawModeDisable(STDIN_FD, saved);3839    var arena = std.heap.ArenaAllocator.init(gpa);40    defer arena.deinit();4142    var query_buf: [256]u8 = undefined;43    var query_len: usize = @min(initial_query.len, query_buf.len - 1);44    @memcpy(query_buf[0..query_len], initial_query[0..query_len]);4546    var packages: []types.NixPackage = &.{};47    var opts: []api.NixOption = &.{};48    var selection: usize = 0;4950    // Initial search51    _ = arena.reset(.free_all);52    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);5354    // Initial render (prints WIDGET_LINES lines)55    renderWidget(query_buf[0..query_len], packages, opts, selection, mode);56    output.flush();5758    // Key loop with debounced search. A keystroke marks the query dirty and59    // stamps the time; the nix search fires only once typing pauses for60    // DEBOUNCE_MS. Stale results stay on screen until the new search returns, so61    // there is no blank flash mid-typing.62    var chosen: ?types.SearchResult = null;63    var query_dirty = false;64    var query_changed_at = std.Io.Clock.awake.now(io);65    loop: while (true) {66        // Block until a key arrives, unless a search is pending — then wake when67        // the debounce window closes so we can fire it.68        var timeout_ms: i32 = -1;69        if (query_dirty) {70            const remaining = DEBOUNCE_MS - elapsedMs(io, query_changed_at);71            timeout_ms = if (remaining <= 0) 0 else @intCast(remaining);72        }7374        var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};75        const ready = std.posix.poll(&pfd, timeout_ms) catch 0;7677        if (ready == 0) {78            // Poll timed out: typing has paused long enough — run the search now.79            if (query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS) {80                query_dirty = false;81                _ = arena.reset(.free_all);82                packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);83                // New result set: reset selection to the top. Navigating during the84                // debounce window could otherwise leave selection past the new end,85                // and renderWidget would index out of bounds.86                selection = 0;87                output.cursorUp(WIDGET_LINES);88                renderWidget(query_buf[0..query_len], packages, opts, selection, mode);89                output.flush();90            }91            continue;92        }9394        var buf: [4]u8 = undefined;95        const n = std.posix.read(STDIN_FD, &buf) catch break;96        if (n == 0) break;9798        if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {99            switch (buf[2]) {100                'A' => if (selection > 0) { selection -= 1; }, // up101                'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down102                else => {},103            }104        } else switch (buf[0]) {105            'k' => if (selection > 0) { selection -= 1; },106            'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },107            0x1b, 'q' => break :loop,108            'i', '\r' => {109                if (mode == .packages and packages.len > selection) {110                    chosen = .{ .package = packages[selection], .action = .profile };111                }112                break :loop;113            },114            's' => {115                if (mode == .packages and packages.len > selection) {116                    chosen = .{ .package = packages[selection], .action = .system };117                }118                break :loop;119            },120            't' => {121                if (mode == .packages and packages.len > selection) {122                    chosen = .{ .package = packages[selection], .action = .profile };123                    // flag: try mode — caller handles124                }125                break :loop;126            },127            'c' => {128                // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)129                if (mode == .packages and packages.len > selection) {130                    copyToClipboard(gpa, io, packages[selection].attr);131                }132            },133            127 => { // backspace134                if (query_len > 0) {135                    query_len -= 1;136                    query_dirty = true;137                    query_changed_at = std.Io.Clock.awake.now(io);138                    selection = 0;139                }140            },141            else => |ch| {142                if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {143                    query_buf[query_len] = ch;144                    query_len += 1;145                    query_dirty = true;146                    query_changed_at = std.Io.Clock.awake.now(io);147                    selection = 0;148                }149            },150        }151152        // Redraw immediately so the query line and selection feel responsive; the153        // search itself is deferred to the debounce tick above.154        output.cursorUp(WIDGET_LINES);155        renderWidget(query_buf[0..query_len], packages, opts, selection, mode);156        output.flush();157    }158159    // Clear the widget area160    output.cursorUp(WIDGET_LINES);161    var i: usize = 0;162    while (i < WIDGET_LINES) : (i += 1) {163        output.raw(output.C_CLEAR_EOL ++ "\n");164    }165    output.cursorUp(WIDGET_LINES);166    output.flush();167168    return chosen;169}170171pub const Mode = enum { packages, options };172173fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {174    switch (mode) {175        .packages => return api.searchPackages(arena, io, query) catch &.{},176        .options => {177            opts.* = api.searchOptions(arena, io, query) catch &.{};178            return &.{};179        },180    }181}182183fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize {184    return switch (mode) {185        .packages => packages.len,186        .options => opts.len,187    };188}189190fn stdinIsTty(io: std.Io) bool {191    return std.Io.File.stdin().isTty(io) catch false;192}193194// Plain, non-interactive rendering for non-tty stdin. Same pink :: header and195// result count the widget shows, then one line per result; no cursor control.196fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode) !?types.SearchResult {197    // An empty query matches the entire catalog (100k+ rows); refuse it cleanly.198    if (std.mem.trim(u8, query, " \t").len == 0) {199        output.searchEmptyQuery();200        output.flush();201        return null;202    }203    var arena = std.heap.ArenaAllocator.init(gpa);204    defer arena.deinit();205    var opts: []api.NixOption = &.{};206    const packages = doSearch(arena.allocator(), io, query, mode, &opts);207    const count = resultCount(packages, opts, mode);208    const mode_str = if (mode == .packages) "nixpkgs" else "options";209    output.searchPlainHeader(mode_str, query, count);210    if (count == 0) {211        output.searchPlainNoResults();212        output.flush();213        return null;214    }215    switch (mode) {216        // unfree=false: per-result meta.unfree would need an eval per package (too217        // slow for the list); the TUI detail pane is the place to surface it.218        .packages => for (packages) |pkg| output.searchPlainPackage(pkg.pname, pkg.version, pkg.description, false),219        .options => for (opts) |opt| output.searchPlainOption(opt.name, opt.type_str),220    }221    output.flush();222    return null;223}224225// A key hint with dim brackets and a pink letter: dim '[', pink label, dim ']'.226// Comptime-built so it's a plain string literal — the widget always renders raw227// ANSI (it only runs on a real tty).228fn keyHint(comptime label: []const u8) []const u8 {229    return output.C_DIM ++ "[" ++ output.C_RESET ++ output.C_PINK ++ label ++ output.C_RESET ++ output.C_DIM ++ "]" ++ output.C_RESET;230}231232fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode) void {233    const count = resultCount(packages, opts, mode);234    const CLR = output.C_CLEAR_EOL;235    const RST = output.C_RESET;236    const PKN = output.C_PINK;237    const BLD = output.C_BOLD;238    const DIM = output.C_DIM;239    const CYN = output.C_CYAN;240241    // Clamp to the current result count: a caller could hand us a selection from a242    // larger, now-replaced result set. Never index past the end.243    const sel = if (count == 0) 0 else @min(selection, count - 1);244245    // Line 1: header246    output.raw(CLR);247    const mode_str = if (mode == .packages) "nixpkgs" else "options";248    output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{249        PKN, RST, mode_str, query, DIM, count, RST,250    });251252    // Line 2: blank253    output.raw(CLR ++ "\n");254255    // Lines 3-7: results (up to RESULT_ROWS), scrolled so selection stays visible256    const scroll: usize = if (sel >= RESULT_ROWS) sel - RESULT_ROWS + 1 else 0;257    var i: usize = 0;258    while (i < RESULT_ROWS) : (i += 1) {259        const idx = scroll + i;260        output.raw(CLR);261        if (idx < count) {262            const selected = (idx == sel);263            switch (mode) {264                .packages => {265                    const pkg = packages[idx];266                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";267                    const name_pre = if (selected) PKN ++ BLD else "";268                    const name_suf = if (selected) RST else "";269                    output.p("   {s}{s}{s}{s}   {s}{s}{s}   {s}\n", .{ marker, name_pre, pkg.pname, name_suf, CYN, pkg.version, RST, pkg.description });270                },271                .options => {272                    const opt = opts[idx];273                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";274                    output.p("   {s}{s}   {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST });275                },276            }277        } else {278            output.raw("\n");279        }280    }281282    // Line 8: blank283    output.raw(CLR ++ "\n");284285    // Lines 9-12: selected package detail286    if (count > 0 and mode == .packages) {287        const pkg = packages[sel];288        output.raw(CLR);289        output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, CYN, pkg.version, RST });290        output.raw(CLR);291        output.p("   {s}\n", .{pkg.description});292        output.raw(CLR ++ "\n");293        output.raw(CLR);294        output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });295    } else if (count > 0 and mode == .options) {296        const opt = opts[sel];297        output.raw(CLR);298        output.p("   {s}  {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST });299        output.raw(CLR);300        output.p("   {s}\n", .{opt.description});301        output.raw(CLR ++ "\n");302        output.raw(CLR ++ "\n");303    } else {304        output.raw(CLR ++ "\n");305        output.raw(CLR ++ "\n");306        output.raw(CLR ++ "\n");307        output.raw(CLR ++ "\n");308    }309310    // Line 13: blank311    output.raw(CLR ++ "\n");312313    // Lines 14-17: controls + padding to reach WIDGET_LINES314    output.raw(CLR);315    if (mode == .packages) {316        output.p("   {s} profile  {s} system  {s} try  {s} copy  {s}[up/down]{s} nav  {s} exit\n", .{317            keyHint("i"), keyHint("s"), keyHint("t"), keyHint("c"), DIM, RST, keyHint("esc"),318        });319    } else {320        output.p("   {s} insert  {s}[up/down]{s} nav  {s} exit\n", .{ keyHint("i"), DIM, RST, keyHint("esc") });321    }322    // Padding to reach WIDGET_LINES (18)323    var pad: usize = 14;324    while (pad < WIDGET_LINES) : (pad += 1) {325        output.raw(CLR ++ "\n");326    }327}328329fn copyToClipboard(gpa: std.mem.Allocator, io: std.Io, text: []const u8) void {330    _ = gpa;331    // Try common clipboard tools332    const tools = [_][]const u8{ "pbcopy", "xclip", "xsel" };333    for (tools) |tool| {334        var child = std.process.spawn(io, .{335            .argv = &.{tool},336            .stdin = .pipe,337            .stdout = .ignore,338            .stderr = .ignore,339        }) catch continue;340        if (child.stdin) |stdin| {341            stdin.writeStreamingAll(io, text) catch {};342            stdin.close(io);343        }344        _ = child.wait(io) catch {};345        return;346    }347}348349fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {350    var tio = try std.posix.tcgetattr(fd);351    saved.* = tio;352    if (comptime builtin.os.tag == .linux) {353        tio.lflag.ECHO = false;354        tio.lflag.ICANON = false;355        tio.lflag.ISIG = false;356        tio.lflag.IEXTEN = false;357        tio.iflag.ICRNL = false;358        tio.iflag.IXON = false;359        tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;360        tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;361    }362    // On non-Linux, terminal will not be in raw mode (TUI requires Linux)363    try std.posix.tcsetattr(fd, .FLUSH, tio);364}365366fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {367    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};368}