// search.zig — inline 18-line TUI widget, no alternate screen // Raw terminal mode for interactive key handling (POSIX: linux + macos). const std = @import("std"); const types = @import("types.zig"); const output = @import("output.zig"); const api = @import("api.zig"); const WIDGET_LINES: usize = 18; const RESULT_ROWS: usize = 5; const STDIN_FD = std.posix.STDIN_FILENO; // Debounce window: a keystroke defers the nix search until typing pauses this // long. nix search is synchronous and blocks the widget for ~1s, so the window // must be long enough that a normal between-keystroke pause doesn't trigger it — // it should only fire once you've actually stopped typing. const DEBOUNCE_MS: i64 = 500; // Milliseconds elapsed since `since` on the monotonic (awake) clock. std has no // milliTimestamp in 0.16; this mirrors the Io.Clock pattern used in apply(). fn elapsedMs(io: std.Io, since: std.Io.Timestamp) i64 { const ns = @max(0, since.untilNow(io, .awake).toNanoseconds()); return @intCast(@divFloor(ns, 1_000_000)); } // What the detail pane shows for the selected package. Tab toggles between the // collapsed (description) view and the expanded meta view; the latter is fetched // on demand, so it passes through loading and (on eval failure) unavailable. const Detail = union(enum) { collapsed, loading, unavailable, meta: api.PkgMeta, }; pub fn run( gpa: std.mem.Allocator, io: std.Io, initial_query: []const u8, mode: Mode, environ: *const std.process.Environ.Map, ) !?types.NixPackage { // Non-interactive fallback: when stdin isn't a terminal (piped, scripted, // CI), print plain results and return instead of the raw-mode widget, which // needs a real tty and would block waiting for keys. if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode, environ); var saved: std.posix.termios = undefined; try rawModeEnable(STDIN_FD, &saved); defer rawModeDisable(STDIN_FD, saved); var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); var query_buf: [256]u8 = undefined; var query_len: usize = @min(initial_query.len, query_buf.len - 1); @memcpy(query_buf[0..query_len], initial_query[0..query_len]); var packages: []types.NixPackage = &.{}; var opts: []api.NixOption = &.{}; var selection: usize = 0; // Expanded detail (Tab): the selected package's meta, fetched on demand into // its own arena and cached by attr so re-visiting is instant. Kept separate // from the search arena, which is reset on every new search. var expanded = false; var meta_arena = std.heap.ArenaAllocator.init(gpa); defer meta_arena.deinit(); var meta: ?api.PkgMeta = null; var meta_attr: []const u8 = ""; // Initial search _ = arena.reset(.free_all); packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts, environ); // Initial render (prints WIDGET_LINES lines) renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .collapsed); output.flush(); // Key loop with debounced search. A keystroke marks the query dirty and // stamps the time; the nix search fires only once typing pauses for // DEBOUNCE_MS. Stale results stay on screen until the new search returns, so // there is no blank flash mid-typing. var chosen: ?types.NixPackage = null; var query_dirty = false; var query_changed_at = std.Io.Clock.awake.now(io); loop: while (true) { // Block until a key arrives, unless a search is pending — then wake when // the debounce window closes so we can fire it. var timeout_ms: i32 = -1; if (query_dirty) { const remaining = DEBOUNCE_MS - elapsedMs(io, query_changed_at); timeout_ms = if (remaining <= 0) 0 else @intCast(remaining); } var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }}; const ready = std.posix.poll(&pfd, timeout_ms) catch 0; if (ready == 0) { // Poll timed out. If no search is pending, nothing changed — keep waiting. if (!(query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS)) continue; query_dirty = false; // Paint "searching..." over the stale results before the blocking nix // search, so the ~1s pause reads as progress, not a freeze. output.cursorUp(WIDGET_LINES); renderWidget(query_buf[0..query_len], packages, opts, selection, mode, true, .collapsed); output.flush(); _ = arena.reset(.free_all); packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts, environ); // New result set: reset to the top and drop cached meta (it belonged to // the previous results). Navigating during the window could otherwise // leave selection past the new end and renderWidget would index OOB. selection = 0; meta = null; // fall through to the common render below } else { var buf: [4]u8 = undefined; const n = std.posix.read(STDIN_FD, &buf) catch break; if (n == 0) break; // The query is a live text field, so every printable key types into it — // single-letter actions would collide with package names (the 'i' in // "ripgrep" must not trigger install). Only non-text keys act: Enter // selects, Tab expands detail, arrows navigate, Esc cancels. if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') { switch (buf[2]) { 'A' => if (selection > 0) { selection -= 1; }, // up 'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down else => {}, } } else switch (buf[0]) { 0x1b => break :loop, // Esc: cancel (chosen stays null) '\t' => expanded = !expanded, // Tab: toggle package detail '\r', '\n' => { // Enter: select the highlighted package if (mode == .packages and packages.len > selection) { chosen = packages[selection]; } break :loop; }, 127, 8 => { // backspace if (query_len > 0) { query_len -= 1; query_dirty = true; query_changed_at = std.Io.Clock.awake.now(io); selection = 0; } }, else => |ch| { if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) { query_buf[query_len] = ch; query_len += 1; query_dirty = true; query_changed_at = std.Io.Clock.awake.now(io); selection = 0; } }, } } // Common render after a search or a key. When expanded, resolve the detail // pane by fetching the selected package's meta on demand (blocking eval), // painting a "loading" frame first and caching by attr so re-visiting is // instant. const count = resultCount(packages, opts, mode); const sel = if (count == 0) 0 else @min(selection, count - 1); var detail: Detail = .collapsed; if (expanded and mode == .packages and count > 0) { const cur = packages[sel].attr; if (meta == null or !std.mem.eql(u8, meta_attr, cur)) { output.cursorUp(WIDGET_LINES); renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .loading); output.flush(); _ = meta_arena.reset(.free_all); meta_attr = meta_arena.allocator().dupe(u8, cur) catch ""; meta = api.packageMeta(meta_arena.allocator(), io, cur); } detail = if (meta) |mm| Detail{ .meta = mm } else Detail.unavailable; } output.cursorUp(WIDGET_LINES); renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, detail); output.flush(); } // Clear the widget area output.cursorUp(WIDGET_LINES); var i: usize = 0; while (i < WIDGET_LINES) : (i += 1) { output.raw(output.C_CLEAR_EOL ++ "\n"); } output.cursorUp(WIDGET_LINES); output.flush(); // The chosen package's strings live in the search arena, which deinits as this // function returns — dupe into the caller's allocator so it survives. if (chosen) |pkg| return dupePackage(gpa, pkg); return null; } fn dupePackage(gpa: std.mem.Allocator, pkg: types.NixPackage) types.NixPackage { return .{ .attr = gpa.dupe(u8, pkg.attr) catch "", .pname = gpa.dupe(u8, pkg.pname) catch "", .version = gpa.dupe(u8, pkg.version) catch "", .description = gpa.dupe(u8, pkg.description) catch "", }; } pub const Mode = enum { packages, options }; fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption, environ: *const std.process.Environ.Map) []types.NixPackage { // An empty query matches the entire catalog (100k+ rows) — a multi-second // fetch that would freeze the widget. Show nothing until there's a query. if (std.mem.trim(u8, query, " \t").len == 0) { opts.* = &.{}; return &.{}; } switch (mode) { .packages => return api.searchPackages(arena, io, environ, query) catch &.{}, .options => { opts.* = api.searchOptions(arena, io, query) catch &.{}; return &.{}; }, } } fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize { return switch (mode) { .packages => packages.len, .options => opts.len, }; } fn stdinIsTty(io: std.Io) bool { return std.Io.File.stdin().isTty(io) catch false; } // Plain, non-interactive rendering for non-tty stdin. Same pink :: header and // result count the widget shows, then one line per result; no cursor control. fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, environ: *const std.process.Environ.Map) !?types.NixPackage { // An empty query matches the entire catalog (100k+ rows); refuse it cleanly. if (std.mem.trim(u8, query, " \t").len == 0) { output.searchEmptyQuery(); output.flush(); return null; } var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); var opts: []api.NixOption = &.{}; const packages = doSearch(arena.allocator(), io, query, mode, &opts, environ); const count = resultCount(packages, opts, mode); const mode_str = if (mode == .packages) "nixpkgs" else "options"; output.searchPlainHeader(mode_str, query, count); if (count == 0) { output.searchPlainNoResults(); output.flush(); return null; } switch (mode) { // unfree=false: per-result meta.unfree would need an eval per package (too // slow for the list); the TUI detail pane is the place to surface it. .packages => for (packages) |pkg| output.searchPlainPackage(pkg.pname, pkg.version, pkg.description, false), .options => for (opts) |opt| output.searchPlainOption(opt.name, opt.type_str), } output.flush(); return null; } // A key hint with dim brackets and a pink letter: dim '[', pink label, dim ']'. // Comptime-built so it's a plain string literal — the widget always renders raw // ANSI (it only runs on a real tty). fn keyHint(comptime label: []const u8) []const u8 { return output.C_DIM ++ "[" ++ output.C_RESET ++ output.C_PINK ++ label ++ output.C_RESET ++ output.C_DIM ++ "]" ++ output.C_RESET; } fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode, searching: bool, detail: Detail) void { const count = resultCount(packages, opts, mode); const CLR = output.C_CLEAR_EOL; const RST = output.C_RESET; const PKN = output.C_PINK; const BLD = output.C_BOLD; const DIM = output.C_DIM; const CYN = output.C_CYAN; // Clamp to the current result count: a caller could hand us a selection from a // larger, now-replaced result set. Never index past the end. const sel = if (count == 0) 0 else @min(selection, count - 1); // Line 1: header. While the (blocking) search runs, show "searching..." so the // pause reads as work-in-progress, not a frozen widget. output.raw(CLR); const mode_str = if (mode == .packages) "nixpkgs" else "options"; if (searching) { output.p("{s}:: {s}search {s} {s} {s}searching...{s}\n", .{ PKN, RST, mode_str, query, DIM, RST, }); } else { output.p("{s}:: {s}search {s} {s} {s}{d} results{s}\n", .{ PKN, RST, mode_str, query, DIM, count, RST, }); } // Line 2: blank output.raw(CLR ++ "\n"); // Lines 3-7: results (up to RESULT_ROWS), scrolled so selection stays visible const scroll: usize = if (sel >= RESULT_ROWS) sel - RESULT_ROWS + 1 else 0; var i: usize = 0; while (i < RESULT_ROWS) : (i += 1) { const idx = scroll + i; output.raw(CLR); if (idx < count) { const selected = (idx == sel); switch (mode) { .packages => { const pkg = packages[idx]; const marker = if (selected) PKN ++ "> " ++ RST else " "; const name_pre = if (selected) PKN ++ BLD else ""; const name_suf = if (selected) RST else ""; output.p(" {s}{s}{s}{s} {s}{s}{s} {s}\n", .{ marker, name_pre, pkg.pname, name_suf, CYN, pkg.version, RST, pkg.description }); }, .options => { const opt = opts[idx]; const marker = if (selected) PKN ++ "> " ++ RST else " "; output.p(" {s}{s} {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST }); }, } } else { output.raw("\n"); } } // Line 8: blank output.raw(CLR ++ "\n"); // Lines 9-12: selected package detail. Line 9 (name+version) is shared; the // next three lines depend on `detail`. Every arm emits exactly 3 lines so the // widget stays WIDGET_LINES tall. if (count > 0 and mode == .packages) { const pkg = packages[sel]; output.raw(CLR); output.p(" {s}{s}{s}{s} {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, CYN, pkg.version, RST }); switch (detail) { .collapsed => { output.raw(CLR); output.p(" {s}\n", .{pkg.description}); output.raw(CLR ++ "\n"); output.raw(CLR); output.p(" {s}attr{s} {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST }); }, .loading => { output.raw(CLR); output.p(" {s}loading details...{s}\n", .{ DIM, RST }); output.raw(CLR ++ "\n"); output.raw(CLR); output.p(" {s}attr{s} {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST }); }, .unavailable => { output.raw(CLR); output.p(" {s}details unavailable{s}\n", .{ DIM, RST }); output.raw(CLR ++ "\n"); output.raw(CLR); output.p(" {s}attr{s} {s}{s}{s}\n", .{ DIM, RST, CYN, pkg.attr, RST }); }, .meta => |m| { const home = if (m.homepage.len > 0) m.homepage else "—"; const lic = if (m.license.len > 0) m.license else "—"; output.raw(CLR); output.p(" {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "home", RST, CYN, home, RST }); output.raw(CLR); output.p(" {s}{s:<8}{s}{s}\n", .{ DIM, "license", RST, lic }); output.raw(CLR); output.p(" {s}{s:<8}{s}{s}{s}{s}\n", .{ DIM, "attr", RST, CYN, pkg.attr, RST }); }, } } else if (count > 0 and mode == .options) { const opt = opts[sel]; output.raw(CLR); output.p(" {s} {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST }); output.raw(CLR); output.p(" {s}\n", .{opt.description}); output.raw(CLR ++ "\n"); output.raw(CLR ++ "\n"); } else { output.raw(CLR ++ "\n"); output.raw(CLR ++ "\n"); output.raw(CLR ++ "\n"); output.raw(CLR ++ "\n"); } // Line 13: blank output.raw(CLR ++ "\n"); // Lines 14-17: controls + padding to reach WIDGET_LINES output.raw(CLR); if (mode == .packages) { output.p(" {s} install {s} info {s}[up/down]{s} nav {s} cancel\n", .{ keyHint("enter"), keyHint("tab"), DIM, RST, keyHint("esc"), }); } else { output.p(" {s}[up/down]{s} nav {s} cancel\n", .{ DIM, RST, keyHint("esc") }); } // Padding to reach WIDGET_LINES (18) var pad: usize = 14; while (pad < WIDGET_LINES) : (pad += 1) { output.raw(CLR ++ "\n"); } } fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void { var tio = try std.posix.tcgetattr(fd); saved.* = tio; // Same flag names exist on every POSIX termios target Zig exposes (linux, // macos, the *BSDs), so one body covers all of them — the old linux-only // guard left macos in cooked mode (echo on, line-buffered). OPOST is left // on so output keeps translating "\n" -> "\r\n" (cursor to column 0). tio.lflag.ECHO = false; tio.lflag.ICANON = false; tio.lflag.ISIG = false; tio.lflag.IEXTEN = false; tio.iflag.ICRNL = false; tio.iflag.IXON = false; tio.cc[@intFromEnum(std.posix.V.MIN)] = 1; tio.cc[@intFromEnum(std.posix.V.TIME)] = 0; try std.posix.tcsetattr(fd, .FLUSH, tio); } fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void { std.posix.tcsetattr(fd, .FLUSH, saved) catch {}; }