search.zig

11.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;1213pub fn run(14    gpa: std.mem.Allocator,15    io: std.Io,16    initial_query: []const u8,17    mode: Mode,18) !?types.SearchResult {19    // Non-interactive fallback: when stdin isn't a terminal (piped, scripted,20    // CI), print plain results and return instead of the raw-mode widget, which21    // needs a real tty and would block waiting for keys.22    if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode);2324    var saved: std.posix.termios = undefined;25    try rawModeEnable(STDIN_FD, &saved);26    defer rawModeDisable(STDIN_FD, saved);2728    var arena = std.heap.ArenaAllocator.init(gpa);29    defer arena.deinit();3031    var query_buf: [256]u8 = undefined;32    var query_len: usize = @min(initial_query.len, query_buf.len - 1);33    @memcpy(query_buf[0..query_len], initial_query[0..query_len]);3435    var packages: []types.NixPackage = &.{};36    var opts: []api.NixOption = &.{};37    var selection: usize = 0;3839    // Initial search40    _ = arena.reset(.free_all);41    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);4243    // Initial render (prints WIDGET_LINES lines)44    renderWidget(query_buf[0..query_len], packages, opts, selection, mode);45    output.flush();4647    // Key loop48    var chosen: ?types.SearchResult = null;49    loop: while (true) {50        var buf: [4]u8 = undefined;51        const n = std.posix.read(STDIN_FD, &buf) catch break;52        if (n == 0) break;5354        var needs_search = false;5556        if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {57            switch (buf[2]) {58                'A' => if (selection > 0) { selection -= 1; }, // up59                'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down60                else => {},61            }62        } else switch (buf[0]) {63            'k' => if (selection > 0) { selection -= 1; },64            'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },65            0x1b, 'q' => break :loop,66            'i', '\r' => {67                if (mode == .packages and packages.len > selection) {68                    chosen = .{ .package = packages[selection], .action = .profile };69                }70                break :loop;71            },72            's' => {73                if (mode == .packages and packages.len > selection) {74                    chosen = .{ .package = packages[selection], .action = .system };75                }76                break :loop;77            },78            't' => {79                if (mode == .packages and packages.len > selection) {80                    chosen = .{ .package = packages[selection], .action = .profile };81                    // flag: try mode — caller handles82                }83                break :loop;84            },85            'c' => {86                // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)87                if (mode == .packages and packages.len > selection) {88                    copyToClipboard(gpa, io, packages[selection].attr);89                }90            },91            127 => { // backspace92                if (query_len > 0) {93                    query_len -= 1;94                    needs_search = true;95                    selection = 0;96                }97            },98            else => |ch| {99                if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {100                    query_buf[query_len] = ch;101                    query_len += 1;102                    needs_search = true;103                    selection = 0;104                }105            },106        }107108        if (needs_search) {109            _ = arena.reset(.free_all);110            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);111        }112113        // Move cursor up and redraw114        output.cursorUp(WIDGET_LINES);115        renderWidget(query_buf[0..query_len], packages, opts, selection, mode);116        output.flush();117    }118119    // Clear the widget area120    output.cursorUp(WIDGET_LINES);121    var i: usize = 0;122    while (i < WIDGET_LINES) : (i += 1) {123        output.raw(output.C_CLEAR_EOL ++ "\n");124    }125    output.cursorUp(WIDGET_LINES);126    output.flush();127128    return chosen;129}130131pub const Mode = enum { packages, options };132133fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {134    switch (mode) {135        .packages => return api.searchPackages(arena, io, query) catch &.{},136        .options => {137            opts.* = api.searchOptions(arena, io, query) catch &.{};138            return &.{};139        },140    }141}142143fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize {144    return switch (mode) {145        .packages => packages.len,146        .options => opts.len,147    };148}149150fn stdinIsTty(io: std.Io) bool {151    return std.Io.File.stdin().isTty(io) catch false;152}153154// Plain, non-interactive rendering for non-tty stdin. Same pink :: header and155// result count the widget shows, then one line per result; no cursor control.156fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode) !?types.SearchResult {157    // An empty query matches the entire catalog (100k+ rows); refuse it cleanly.158    if (std.mem.trim(u8, query, " \t").len == 0) {159        output.searchEmptyQuery();160        output.flush();161        return null;162    }163    var arena = std.heap.ArenaAllocator.init(gpa);164    defer arena.deinit();165    var opts: []api.NixOption = &.{};166    const packages = doSearch(arena.allocator(), io, query, mode, &opts);167    const count = resultCount(packages, opts, mode);168    const mode_str = if (mode == .packages) "nixpkgs" else "options";169    output.searchPlainHeader(mode_str, query, count);170    if (count == 0) {171        output.searchPlainNoResults();172        output.flush();173        return null;174    }175    switch (mode) {176        // unfree=false: per-result meta.unfree would need an eval per package (too177        // slow for the list); the TUI detail pane is the place to surface it.178        .packages => for (packages) |pkg| output.searchPlainPackage(pkg.pname, pkg.version, pkg.description, false),179        .options => for (opts) |opt| output.searchPlainOption(opt.name, opt.type_str),180    }181    output.flush();182    return null;183}184185fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode) void {186    const count = resultCount(packages, opts, mode);187    const CLR = output.C_CLEAR_EOL;188    const RST = output.C_RESET;189    const PKN = output.C_PINK;190    const BLD = output.C_BOLD;191    const DIM = output.C_DIM;192193    // Line 1: header194    output.raw(CLR);195    const mode_str = if (mode == .packages) "nixpkgs" else "options";196    output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{197        PKN, RST, mode_str, query, DIM, count, RST,198    });199200    // Line 2: blank201    output.raw(CLR ++ "\n");202203    // Lines 3-7: results (up to RESULT_ROWS), scrolled so selection stays visible204    const scroll: usize = if (selection >= RESULT_ROWS) selection - RESULT_ROWS + 1 else 0;205    var i: usize = 0;206    while (i < RESULT_ROWS) : (i += 1) {207        const idx = scroll + i;208        output.raw(CLR);209        if (idx < count) {210            const selected = (idx == selection);211            switch (mode) {212                .packages => {213                    const pkg = packages[idx];214                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";215                    const name_pre = if (selected) PKN ++ BLD else "";216                    const name_suf = if (selected) RST else "";217                    output.p("   {s}{s}{s}{s}   {s}{s}{s}   {s}\n", .{ marker, name_pre, pkg.pname, name_suf, DIM, pkg.version, RST, pkg.description });218                },219                .options => {220                    const opt = opts[idx];221                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";222                    output.p("   {s}{s}   {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST });223                },224            }225        } else {226            output.raw("\n");227        }228    }229230    // Line 8: blank231    output.raw(CLR ++ "\n");232233    // Lines 9-12: selected package detail234    if (count > 0 and mode == .packages) {235        const pkg = packages[selection];236        output.raw(CLR);237        output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, DIM, pkg.version, RST });238        output.raw(CLR);239        output.p("   {s}\n", .{pkg.description});240        output.raw(CLR ++ "\n");241        output.raw(CLR);242        output.p("   {s}attr  {s}{s}\n", .{ DIM, pkg.attr, RST });243    } else if (count > 0 and mode == .options) {244        const opt = opts[selection];245        output.raw(CLR);246        output.p("   {s}  {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST });247        output.raw(CLR);248        output.p("   {s}\n", .{opt.description});249        output.raw(CLR ++ "\n");250        output.raw(CLR ++ "\n");251    } else {252        output.raw(CLR ++ "\n");253        output.raw(CLR ++ "\n");254        output.raw(CLR ++ "\n");255        output.raw(CLR ++ "\n");256    }257258    // Line 13: blank259    output.raw(CLR ++ "\n");260261    // Lines 14-17: controls + padding to reach WIDGET_LINES262    output.raw(CLR);263    if (mode == .packages) {264        output.p("   {s}[i] profile  [s] system  [t] try  [c] copy  [up/down] nav  [esc] exit{s}\n", .{ DIM, RST });265    } else {266        output.p("   {s}[i] insert  [up/down] nav  [esc] exit{s}\n", .{ DIM, RST });267    }268    // Padding to reach WIDGET_LINES (18)269    var pad: usize = 14;270    while (pad < WIDGET_LINES) : (pad += 1) {271        output.raw(CLR ++ "\n");272    }273}274275fn copyToClipboard(gpa: std.mem.Allocator, io: std.Io, text: []const u8) void {276    _ = gpa;277    // Try common clipboard tools278    const tools = [_][]const u8{ "pbcopy", "xclip", "xsel" };279    for (tools) |tool| {280        var child = std.process.spawn(io, .{281            .argv = &.{tool},282            .stdin = .pipe,283            .stdout = .ignore,284            .stderr = .ignore,285        }) catch continue;286        if (child.stdin) |stdin| {287            stdin.writeStreamingAll(io, text) catch {};288            stdin.close(io);289        }290        _ = child.wait(io) catch {};291        return;292    }293}294295fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {296    var tio = try std.posix.tcgetattr(fd);297    saved.* = tio;298    if (comptime builtin.os.tag == .linux) {299        tio.lflag.ECHO = false;300        tio.lflag.ICANON = false;301        tio.lflag.ISIG = false;302        tio.lflag.IEXTEN = false;303        tio.iflag.ICRNL = false;304        tio.iflag.IXON = false;305        tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;306        tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;307    }308    // On non-Linux, terminal will not be in raw mode (TUI requires Linux)309    try std.posix.tcsetattr(fd, .FLUSH, tio);310}311312fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {313    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};314}