search.zig

15.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. 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}2526pub fn run(27    gpa: std.mem.Allocator,28    io: std.Io,29    initial_query: []const u8,30    mode: Mode,31) !?types.SearchResult {32    // Non-interactive fallback: when stdin isn't a terminal (piped, scripted,33    // CI), print plain results and return instead of the raw-mode widget, which34    // needs a real tty and would block waiting for keys.35    if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode);3637    var saved: std.posix.termios = undefined;38    try rawModeEnable(STDIN_FD, &saved);39    defer rawModeDisable(STDIN_FD, saved);4041    var arena = std.heap.ArenaAllocator.init(gpa);42    defer arena.deinit();4344    var query_buf: [256]u8 = undefined;45    var query_len: usize = @min(initial_query.len, query_buf.len - 1);46    @memcpy(query_buf[0..query_len], initial_query[0..query_len]);4748    var packages: []types.NixPackage = &.{};49    var opts: []api.NixOption = &.{};50    var selection: usize = 0;5152    // Initial search53    _ = arena.reset(.free_all);54    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);5556    // Initial render (prints WIDGET_LINES lines)57    renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);58    output.flush();5960    // Key loop with debounced search. A keystroke marks the query dirty and61    // stamps the time; the nix search fires only once typing pauses for62    // DEBOUNCE_MS. Stale results stay on screen until the new search returns, so63    // there is no blank flash mid-typing.64    var chosen: ?types.SearchResult = null;65    var query_dirty = false;66    var query_changed_at = std.Io.Clock.awake.now(io);67    loop: while (true) {68        // Block until a key arrives, unless a search is pending — then wake when69        // the debounce window closes so we can fire it.70        var timeout_ms: i32 = -1;71        if (query_dirty) {72            const remaining = DEBOUNCE_MS - elapsedMs(io, query_changed_at);73            timeout_ms = if (remaining <= 0) 0 else @intCast(remaining);74        }7576        var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};77        const ready = std.posix.poll(&pfd, timeout_ms) catch 0;7879        if (ready == 0) {80            // Poll timed out: typing has paused long enough — run the search now.81            if (query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS) {82                query_dirty = false;83                // Paint "searching..." over the stale results before the blocking84                // nix search, so the ~1s pause reads as progress, not a freeze.85                output.cursorUp(WIDGET_LINES);86                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, true);87                output.flush();8889                _ = arena.reset(.free_all);90                packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);91                // New result set: reset selection to the top. Navigating during the92                // debounce window could otherwise leave selection past the new end,93                // and renderWidget would index out of bounds.94                selection = 0;95                output.cursorUp(WIDGET_LINES);96                renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);97                output.flush();98            }99            continue;100        }101102        var buf: [4]u8 = undefined;103        const n = std.posix.read(STDIN_FD, &buf) catch break;104        if (n == 0) break;105106        if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {107            switch (buf[2]) {108                'A' => if (selection > 0) { selection -= 1; }, // up109                'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down110                else => {},111            }112        } else switch (buf[0]) {113            'k' => if (selection > 0) { selection -= 1; },114            'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },115            0x1b, 'q' => break :loop,116            'i', '\r' => {117                if (mode == .packages and packages.len > selection) {118                    chosen = .{ .package = packages[selection], .action = .profile };119                }120                break :loop;121            },122            's' => {123                if (mode == .packages and packages.len > selection) {124                    chosen = .{ .package = packages[selection], .action = .system };125                }126                break :loop;127            },128            't' => {129                if (mode == .packages and packages.len > selection) {130                    chosen = .{ .package = packages[selection], .action = .profile };131                    // flag: try mode — caller handles132                }133                break :loop;134            },135            'c' => {136                // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)137                if (mode == .packages and packages.len > selection) {138                    copyToClipboard(gpa, io, packages[selection].attr);139                }140            },141            127 => { // backspace142                if (query_len > 0) {143                    query_len -= 1;144                    query_dirty = true;145                    query_changed_at = std.Io.Clock.awake.now(io);146                    selection = 0;147                }148            },149            else => |ch| {150                if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {151                    query_buf[query_len] = ch;152                    query_len += 1;153                    query_dirty = true;154                    query_changed_at = std.Io.Clock.awake.now(io);155                    selection = 0;156                }157            },158        }159160        // Redraw immediately so the query line and selection feel responsive; the161        // search itself is deferred to the debounce tick above.162        output.cursorUp(WIDGET_LINES);163        renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false);164        output.flush();165    }166167    // Clear the widget area168    output.cursorUp(WIDGET_LINES);169    var i: usize = 0;170    while (i < WIDGET_LINES) : (i += 1) {171        output.raw(output.C_CLEAR_EOL ++ "\n");172    }173    output.cursorUp(WIDGET_LINES);174    output.flush();175176    return chosen;177}178179pub const Mode = enum { packages, options };180181fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {182    // An empty query matches the entire catalog (100k+ rows) — a multi-second183    // fetch that would freeze the widget. Show nothing until there's a query.184    if (std.mem.trim(u8, query, " \t").len == 0) {185        opts.* = &.{};186        return &.{};187    }188    switch (mode) {189        .packages => return api.searchPackages(arena, io, query) catch &.{},190        .options => {191            opts.* = api.searchOptions(arena, io, query) catch &.{};192            return &.{};193        },194    }195}196197fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize {198    return switch (mode) {199        .packages => packages.len,200        .options => opts.len,201    };202}203204fn stdinIsTty(io: std.Io) bool {205    return std.Io.File.stdin().isTty(io) catch false;206}207208// Plain, non-interactive rendering for non-tty stdin. Same pink :: header and209// result count the widget shows, then one line per result; no cursor control.210fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode) !?types.SearchResult {211    // An empty query matches the entire catalog (100k+ rows); refuse it cleanly.212    if (std.mem.trim(u8, query, " \t").len == 0) {213        output.searchEmptyQuery();214        output.flush();215        return null;216    }217    var arena = std.heap.ArenaAllocator.init(gpa);218    defer arena.deinit();219    var opts: []api.NixOption = &.{};220    const packages = doSearch(arena.allocator(), io, query, mode, &opts);221    const count = resultCount(packages, opts, mode);222    const mode_str = if (mode == .packages) "nixpkgs" else "options";223    output.searchPlainHeader(mode_str, query, count);224    if (count == 0) {225        output.searchPlainNoResults();226        output.flush();227        return null;228    }229    switch (mode) {230        // unfree=false: per-result meta.unfree would need an eval per package (too231        // slow for the list); the TUI detail pane is the place to surface it.232        .packages => for (packages) |pkg| output.searchPlainPackage(pkg.pname, pkg.version, pkg.description, false),233        .options => for (opts) |opt| output.searchPlainOption(opt.name, opt.type_str),234    }235    output.flush();236    return null;237}238239// A key hint with dim brackets and a pink letter: dim '[', pink label, dim ']'.240// Comptime-built so it's a plain string literal — the widget always renders raw241// ANSI (it only runs on a real tty).242fn keyHint(comptime label: []const u8) []const u8 {243    return output.C_DIM ++ "[" ++ output.C_RESET ++ output.C_PINK ++ label ++ output.C_RESET ++ output.C_DIM ++ "]" ++ output.C_RESET;244}245246fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode, searching: bool) void {247    const count = resultCount(packages, opts, mode);248    const CLR = output.C_CLEAR_EOL;249    const RST = output.C_RESET;250    const PKN = output.C_PINK;251    const BLD = output.C_BOLD;252    const DIM = output.C_DIM;253    const CYN = output.C_CYAN;254255    // Clamp to the current result count: a caller could hand us a selection from a256    // larger, now-replaced result set. Never index past the end.257    const sel = if (count == 0) 0 else @min(selection, count - 1);258259    // Line 1: header. While the (blocking) search runs, show "searching..." so the260    // pause reads as work-in-progress, not a frozen widget.261    output.raw(CLR);262    const mode_str = if (mode == .packages) "nixpkgs" else "options";263    if (searching) {264        output.p("{s}:: {s}search {s} {s}  {s}searching...{s}\n", .{265            PKN, RST, mode_str, query, DIM, RST,266        });267    } else {268        output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{269            PKN, RST, mode_str, query, DIM, count, RST,270        });271    }272273    // Line 2: blank274    output.raw(CLR ++ "\n");275276    // Lines 3-7: results (up to RESULT_ROWS), scrolled so selection stays visible277    const scroll: usize = if (sel >= RESULT_ROWS) sel - RESULT_ROWS + 1 else 0;278    var i: usize = 0;279    while (i < RESULT_ROWS) : (i += 1) {280        const idx = scroll + i;281        output.raw(CLR);282        if (idx < count) {283            const selected = (idx == sel);284            switch (mode) {285                .packages => {286                    const pkg = packages[idx];287                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";288                    const name_pre = if (selected) PKN ++ BLD else "";289                    const name_suf = if (selected) RST else "";290                    output.p("   {s}{s}{s}{s}   {s}{s}{s}   {s}\n", .{ marker, name_pre, pkg.pname, name_suf, CYN, pkg.version, RST, pkg.description });291                },292                .options => {293                    const opt = opts[idx];294                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";295                    output.p("   {s}{s}   {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST });296                },297            }298        } else {299            output.raw("\n");300        }301    }302303    // Line 8: blank304    output.raw(CLR ++ "\n");305306    // Lines 9-12: selected package detail307    if (count > 0 and mode == .packages) {308        const pkg = packages[sel];309        output.raw(CLR);310        output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, CYN, pkg.version, RST });311        output.raw(CLR);312        output.p("   {s}\n", .{pkg.description});313        output.raw(CLR ++ "\n");314        output.raw(CLR);315        output.p("   {s}attr{s}  {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST });316    } else if (count > 0 and mode == .options) {317        const opt = opts[sel];318        output.raw(CLR);319        output.p("   {s}  {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST });320        output.raw(CLR);321        output.p("   {s}\n", .{opt.description});322        output.raw(CLR ++ "\n");323        output.raw(CLR ++ "\n");324    } else {325        output.raw(CLR ++ "\n");326        output.raw(CLR ++ "\n");327        output.raw(CLR ++ "\n");328        output.raw(CLR ++ "\n");329    }330331    // Line 13: blank332    output.raw(CLR ++ "\n");333334    // Lines 14-17: controls + padding to reach WIDGET_LINES335    output.raw(CLR);336    if (mode == .packages) {337        output.p("   {s} profile  {s} system  {s} try  {s} copy  {s}[up/down]{s} nav  {s} exit\n", .{338            keyHint("i"), keyHint("s"), keyHint("t"), keyHint("c"), DIM, RST, keyHint("esc"),339        });340    } else {341        output.p("   {s} insert  {s}[up/down]{s} nav  {s} exit\n", .{ keyHint("i"), DIM, RST, keyHint("esc") });342    }343    // Padding to reach WIDGET_LINES (18)344    var pad: usize = 14;345    while (pad < WIDGET_LINES) : (pad += 1) {346        output.raw(CLR ++ "\n");347    }348}349350fn copyToClipboard(gpa: std.mem.Allocator, io: std.Io, text: []const u8) void {351    _ = gpa;352    // Try common clipboard tools353    const tools = [_][]const u8{ "pbcopy", "xclip", "xsel" };354    for (tools) |tool| {355        var child = std.process.spawn(io, .{356            .argv = &.{tool},357            .stdin = .pipe,358            .stdout = .ignore,359            .stderr = .ignore,360        }) catch continue;361        if (child.stdin) |stdin| {362            stdin.writeStreamingAll(io, text) catch {};363            stdin.close(io);364        }365        _ = child.wait(io) catch {};366        return;367    }368}369370fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {371    var tio = try std.posix.tcgetattr(fd);372    saved.* = tio;373    if (comptime builtin.os.tag == .linux) {374        tio.lflag.ECHO = false;375        tio.lflag.ICANON = false;376        tio.lflag.ISIG = false;377        tio.lflag.IEXTEN = false;378        tio.iflag.ICRNL = false;379        tio.iflag.IXON = false;380        tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;381        tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;382    }383    // On non-Linux, terminal will not be in raw mode (TUI requires Linux)384    try std.posix.tcsetattr(fd, .FLUSH, tio);385}386387fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {388    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};389}