search.zig

9.2 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    var saved: std.posix.termios = undefined;20    try rawModeEnable(STDIN_FD, &saved);21    defer rawModeDisable(STDIN_FD, saved);2223    var arena = std.heap.ArenaAllocator.init(gpa);24    defer arena.deinit();2526    var query_buf: [256]u8 = undefined;27    var query_len: usize = @min(initial_query.len, query_buf.len - 1);28    @memcpy(query_buf[0..query_len], initial_query[0..query_len]);2930    var packages: []types.NixPackage = &.{};31    var opts: []api.NixOption = &.{};32    var selection: usize = 0;3334    // Initial search35    _ = arena.reset(.free_all);36    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);3738    // Initial render (prints WIDGET_LINES lines)39    renderWidget(query_buf[0..query_len], packages, opts, selection, mode);40    output.flush();4142    // Key loop43    var chosen: ?types.SearchResult = null;44    loop: while (true) {45        var buf: [4]u8 = undefined;46        const n = std.posix.read(STDIN_FD, &buf) catch break;47        if (n == 0) break;4849        var needs_search = false;5051        if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {52            switch (buf[2]) {53                'A' => if (selection > 0) { selection -= 1; }, // up54                'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down55                else => {},56            }57        } else switch (buf[0]) {58            'k' => if (selection > 0) { selection -= 1; },59            'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },60            0x1b, 'q' => break :loop,61            'i', '\r' => {62                if (mode == .packages and packages.len > selection) {63                    chosen = .{ .package = packages[selection], .action = .profile };64                }65                break :loop;66            },67            's' => {68                if (mode == .packages and packages.len > selection) {69                    chosen = .{ .package = packages[selection], .action = .system };70                }71                break :loop;72            },73            't' => {74                if (mode == .packages and packages.len > selection) {75                    chosen = .{ .package = packages[selection], .action = .profile };76                    // flag: try mode — caller handles77                }78                break :loop;79            },80            'c' => {81                // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)82                if (mode == .packages and packages.len > selection) {83                    copyToClipboard(gpa, io, packages[selection].attr);84                }85            },86            127 => { // backspace87                if (query_len > 0) {88                    query_len -= 1;89                    needs_search = true;90                    selection = 0;91                }92            },93            else => |ch| {94                if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {95                    query_buf[query_len] = ch;96                    query_len += 1;97                    needs_search = true;98                    selection = 0;99                }100            },101        }102103        if (needs_search) {104            _ = arena.reset(.free_all);105            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);106        }107108        // Move cursor up and redraw109        output.cursorUp(WIDGET_LINES);110        renderWidget(query_buf[0..query_len], packages, opts, selection, mode);111        output.flush();112    }113114    // Clear the widget area115    output.cursorUp(WIDGET_LINES);116    var i: usize = 0;117    while (i < WIDGET_LINES) : (i += 1) {118        output.raw(output.C_CLEAR_EOL ++ "\n");119    }120    output.cursorUp(WIDGET_LINES);121    output.flush();122123    return chosen;124}125126pub const Mode = enum { packages, options };127128fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {129    switch (mode) {130        .packages => return api.searchPackages(arena, io, query) catch &.{},131        .options => {132            opts.* = api.searchOptions(arena, io, query) catch &.{};133            return &.{};134        },135    }136}137138fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize {139    return switch (mode) {140        .packages => packages.len,141        .options => opts.len,142    };143}144145fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode) void {146    const count = resultCount(packages, opts, mode);147    const CLR = output.C_CLEAR_EOL;148    const RST = output.C_RESET;149    const PKN = output.C_PINK;150    const BLD = output.C_BOLD;151    const DIM = output.C_DIM;152153    // Line 1: header154    output.raw(CLR);155    const mode_str = if (mode == .packages) "nixpkgs" else "options";156    output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{157        PKN, RST, mode_str, query, DIM, count, RST,158    });159160    // Line 2: blank161    output.raw(CLR ++ "\n");162163    // Lines 3-7: results (up to RESULT_ROWS)164    var i: usize = 0;165    while (i < RESULT_ROWS) : (i += 1) {166        output.raw(CLR);167        if (i < count) {168            const selected = (i == selection);169            switch (mode) {170                .packages => {171                    const pkg = packages[i];172                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";173                    const name_pre = if (selected) PKN ++ BLD else "";174                    const name_suf = if (selected) RST else "";175                    output.p("   {s}{s}{s}{s}   {s}{s}{s}   {s}\n", .{ marker, name_pre, pkg.pname, name_suf, DIM, pkg.version, RST, pkg.description });176                },177                .options => {178                    const opt = opts[i];179                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";180                    output.p("   {s}{s}   {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST });181                },182            }183        } else {184            output.raw("\n");185        }186    }187188    // Line 8: blank189    output.raw(CLR ++ "\n");190191    // Lines 9-12: selected package detail192    if (count > 0 and mode == .packages) {193        const pkg = packages[selection];194        output.raw(CLR);195        output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, DIM, pkg.version, RST });196        output.raw(CLR);197        output.p("   {s}\n", .{pkg.description});198        output.raw(CLR ++ "\n");199        output.raw(CLR);200        output.p("   {s}attr  {s}{s}\n", .{ DIM, pkg.attr, RST });201    } else if (count > 0 and mode == .options) {202        const opt = opts[selection];203        output.raw(CLR);204        output.p("   {s}  {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST });205        output.raw(CLR);206        output.p("   {s}\n", .{opt.description});207        output.raw(CLR ++ "\n");208        output.raw(CLR ++ "\n");209    } else {210        output.raw(CLR ++ "\n");211        output.raw(CLR ++ "\n");212        output.raw(CLR ++ "\n");213        output.raw(CLR ++ "\n");214    }215216    // Line 13: blank217    output.raw(CLR ++ "\n");218219    // Lines 14-17: controls + padding to reach WIDGET_LINES220    output.raw(CLR);221    if (mode == .packages) {222        output.p("   {s}[i] profile  [s] system  [t] try  [c] copy  [up/down] nav  [esc] exit{s}\n", .{ DIM, RST });223    } else {224        output.p("   {s}[i] insert  [up/down] nav  [esc] exit{s}\n", .{ DIM, RST });225    }226    // Padding to reach WIDGET_LINES (18)227    var pad: usize = 14;228    while (pad < WIDGET_LINES) : (pad += 1) {229        output.raw(CLR ++ "\n");230    }231}232233fn copyToClipboard(gpa: std.mem.Allocator, io: std.Io, text: []const u8) void {234    _ = gpa;235    // Try common clipboard tools236    const tools = [_][]const u8{ "pbcopy", "xclip", "xsel" };237    for (tools) |tool| {238        var child = std.process.spawn(io, .{239            .argv = &.{tool},240            .stdin = .pipe,241            .stdout = .ignore,242            .stderr = .ignore,243        }) catch continue;244        if (child.stdin) |stdin| {245            stdin.writeStreamingAll(io, text) catch {};246            stdin.close(io);247        }248        _ = child.wait(io) catch {};249        return;250    }251}252253fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {254    var tio = try std.posix.tcgetattr(fd);255    saved.* = tio;256    if (comptime builtin.os.tag == .linux) {257        tio.lflag.ECHO = false;258        tio.lflag.ICANON = false;259        tio.lflag.ISIG = false;260        tio.lflag.IEXTEN = false;261        tio.iflag.ICRNL = false;262        tio.iflag.IXON = false;263        tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;264        tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;265    }266    // On non-Linux, terminal will not be in raw mode (TUI requires Linux)267    try std.posix.tcsetattr(fd, .FLUSH, tio);268}269270fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {271    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};272}