srun

init

1b106d6feae1550e44b8ac0aac05dde8d1d65fc0

SM <seb.michalk@gmail.com>

2026-04-27 13:11:23 +0000

 build.zig    |  17 +++
 readme.txt   |  56 ++++++++
 src/main.zig | 452 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 srun         |   1 +
 4 files changed, 526 insertions(+)

diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..00c65a8
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,17 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+    const target = b.standardTargetOptions(.{});
+    const optimize = b.standardOptimizeOption(.{});
+    const exe = b.addExecutable(.{
+        .name = "srun",
+        .root_source_file = b.path("src/main.zig"),
+        .target = target,
+        .optimize = optimize,
+    });
+    exe.linkLibC();
+    exe.linkSystemLibrary("x11");
+    exe.linkSystemLibrary("xft");
+    exe.linkSystemLibrary("fontconfig");
+    b.installArtifact(exe);
+}
diff --git a/readme.txt b/readme.txt
new file mode 100644
index 0000000..968b24a
--- /dev/null
+++ b/readme.txt
@@ -0,0 +1,56 @@
+srun
+====
+
+srun reads a list of entries from stdin, displays them in an X11
+window, and lets you filter by typing. Press enter to launch the
+selected command. Designed to be piped into e.g:
+
+    ls /usr/bin | srun
+
+In order to build srun you need:
+- Zig
+- X11 (libx11-dev)
+- Xft (libxft-dev)
+- fontconfig (libfontconfig-dev)
+
+Build
+-----
+    zig build
+
+The binary is written to zig-out/bin/srun.
+
+Options
+-------
+    -l lines    number of visible match lines (default: 20)
+    -fn font    Xft font string (default: monospace:size=11)
+    -p prompt   prompt string (default: "> ")
+    -w width    window width in pixels (default: 600)
+    -nb color   normal background (default: #1e1e2e)
+    -nf color   normal foreground (default: #cdd6f4)
+    -sb color   selected background (default: #45475a)
+    -sf color   selected foreground (default: #f5e0dc)
+    -bc color   border + prompt color (default: #89b4fa)
+
+Key bindings
+------------
+    Up / Ctrl+P     move selection up
+    Down / Ctrl+N   move selection down
+    Home            select first match
+    End             select last match
+    Backspace       delete last character
+    Ctrl+U          clear input
+    Enter           launch selected command
+    Escape          quit without launching
+
+
+How it works
+------------
+The window is created with override_redirect at the top-center of the
+screen. Keyboard is grabbed on spawn. Entries are read into a static
+buffer (512 KB, max 4096 entries). Filtering is case-insensitive
+substring match. Selected commands are run via /bin/sh -c.
+
+
+License
+-------
+MIT
diff --git a/src/main.zig b/src/main.zig
new file mode 100644
index 0000000..ea461d1
--- /dev/null
+++ b/src/main.zig
@@ -0,0 +1,452 @@
+// srun
+// Reads from stdin. Type to filter, up/down to select, enter to launch.
+// See LICENSE for copyright license details.
+
+const std = @import("std");
+
+const c = @cImport({
+    @cInclude("X11/Xlib.h");
+    @cInclude("X11/Xutil.h");
+    @cInclude("X11/Xft/Xft.h");
+    @cInclude("fontconfig/fontconfig.h");
+});
+
+// ---- config ----
+
+const dfont = "monospace:size=11";
+const dprompt = "> ";
+const dbg = "#1e1e2e";
+const dfg = "#cdd6f4";
+const dselbg = "#45475a";
+const dselfg = "#f5e0dc";
+const dbdc = "#89b4fa";
+const dlines: u32 = 20;
+const dwidth: u32 = 600;
+const line_h: i32 = 24;
+const border_px: u32 = 2;
+const pad: i32 = 6;
+
+// ---- limits ----
+
+const max_entries = 4096;
+const max_input = 512;
+const stdin_buf_sz: usize = 1 << 19;
+
+// ---- keysyms ----
+
+const XK_Return: c_ulong = 0xff0d;
+const XK_Escape: c_ulong = 0xff1b;
+const XK_BackSpace: c_ulong = 0xff08;
+const XK_Delete: c_ulong = 0xffff;
+const XK_Up: c_ulong = 0xff52;
+const XK_Down: c_ulong = 0xff54;
+const XK_Home: c_ulong = 0xff50;
+const XK_End: c_ulong = 0xff57;
+
+// ---- config state (overridable via flags) ----
+
+var sfont: [256]u8 = undefined;
+var sprompt: [128]u8 = undefined;
+var sbg: [32]u8 = undefined;
+var sfg: [32]u8 = undefined;
+var sselbg: [32]u8 = undefined;
+var sselfg: [32]u8 = undefined;
+var sbdc: [32]u8 = undefined;
+var cfg_lines: u32 = dlines;
+var cfg_width: u32 = dwidth;
+
+fn setZ(buf: []u8, val: []const u8) [*:0]const u8 {
+    if (val.len >= buf.len) die("string too long", .{});
+    @memcpy(buf[0..val.len], val);
+    buf[val.len] = 0;
+    return buf[0..val.len :0].ptr;
+}
+
+fn zptr(buf: []u8) [*:0]const u8 {
+    const end = std.mem.indexOfScalar(u8, buf, 0) orelse buf.len;
+    return buf[0..end :0].ptr;
+}
+
+// ---- entry state ----
+
+var stdin_buf: [stdin_buf_sz]u8 = undefined;
+var entries: [max_entries][]const u8 = undefined;
+var nentries: usize = 0;
+var matched: [max_entries]usize = undefined;
+var nmatched: usize = 0;
+var input: std.BoundedArray(u8, max_input) = .{};
+var sel: usize = 0;
+var off: usize = 0;
+
+// ---- X11 state ----
+
+var dpy: *c.Display = undefined;
+var win: c.Window = undefined;
+var xftdraw: *c.XftDraw = undefined;
+var xftfont: *c.XftFont = undefined;
+var visual: *c.Visual = undefined;
+var cmap: c.Colormap = undefined;
+var xbg: c.XftColor = undefined;
+var xfg: c.XftColor = undefined;
+var xselbg: c.XftColor = undefined;
+var xselfg: c.XftColor = undefined;
+var xprompt: c.XftColor = undefined;
+
+// ---- helpers ----
+
+fn die(comptime fmt: []const u8, args: anytype) noreturn {
+    const w = std.io.getStdErr().writer();
+    w.print("srun: " ++ fmt, args) catch {};
+    w.writeByte('\n') catch {};
+    std.process.exit(1);
+}
+
+fn usage() noreturn {
+    const w = std.io.getStdErr().writer();
+    w.writeAll(
+        \\usage: srun [-l lines] [-fn font] [-p prompt] [-w width]
+        \\              [-nb color] [-nf color] [-sb color] [-sf color] [-bc color]
+        \\
+    ) catch {};
+    std.process.exit(1);
+}
+
+fn xftColor(name: [*:0]const u8) c.XftColor {
+    var color: c.XftColor = undefined;
+    if (c.XftColorAllocName(dpy, visual, cmap, name, &color) == 0)
+        die("cannot allocate color: {s}", .{name});
+    return color;
+}
+
+fn winH() u32 {
+    return (cfg_lines + 1) * @as(u32, @intCast(line_h));
+}
+
+// ---- arg parsing ----
+
+fn parseArgs() void {
+    var args = std.process.args();
+    _ = args.next() orelse return;
+    while (args.next()) |a| {
+        const eq = struct {
+            fn is(s: []const u8, t: []const u8) bool {
+                return std.mem.eql(u8, s, t);
+            }
+        }.is;
+        if (eq(a, "-l")) {
+            const v = args.next() orelse usage();
+            cfg_lines = std.fmt.parseInt(u32, v, 10) catch die("bad number: {s}", .{v});
+        } else if (eq(a, "-fn")) {
+            _ = setZ(&sfont, args.next() orelse usage());
+        } else if (eq(a, "-p")) {
+            _ = setZ(&sprompt, args.next() orelse usage());
+        } else if (eq(a, "-w")) {
+            const v = args.next() orelse usage();
+            cfg_width = std.fmt.parseInt(u32, v, 10) catch die("bad number: {s}", .{v});
+        } else if (eq(a, "-nb")) {
+            _ = setZ(&sbg, args.next() orelse usage());
+        } else if (eq(a, "-nf")) {
+            _ = setZ(&sfg, args.next() orelse usage());
+        } else if (eq(a, "-sb")) {
+            _ = setZ(&sselbg, args.next() orelse usage());
+        } else if (eq(a, "-sf")) {
+            _ = setZ(&sselfg, args.next() orelse usage());
+        } else if (eq(a, "-bc")) {
+            _ = setZ(&sbdc, args.next() orelse usage());
+        } else {
+            usage();
+        }
+    }
+}
+
+// ---- entry reading ----
+
+fn readEntries() void {
+    const stdin = std.io.getStdIn();
+    const n = stdin.readAll(&stdin_buf) catch die("read stdin", .{});
+    var start: usize = 0;
+    for (stdin_buf[0..n], 0..) |ch, i| {
+        if (ch == '\n') {
+            if (i > start and nentries < max_entries) {
+                entries[nentries] = stdin_buf[start..i];
+                nentries += 1;
+            }
+            start = i + 1;
+        }
+    }
+    if (start < n and nentries < max_entries) {
+        entries[nentries] = stdin_buf[start..n];
+        nentries += 1;
+    }
+}
+
+// ---- matching ----
+
+fn toLower(ch: u8) u8 {
+    return if (ch >= 'A' and ch <= 'Z') ch + 32 else ch;
+}
+
+fn containsI(hay: []const u8, needle: []const u8) bool {
+    if (needle.len == 0) return true;
+    if (needle.len > hay.len) return false;
+    var i: usize = 0;
+    while (i + needle.len <= hay.len) : (i += 1) {
+        var ok = true;
+        for (needle, 0..) |ch, j| {
+            if (toLower(hay[i + j]) != toLower(ch)) {
+                ok = false;
+                break;
+            }
+        }
+        if (ok) return true;
+    }
+    return false;
+}
+
+fn matchEntries() void {
+    nmatched = 0;
+    const pat = input.slice();
+    for (0..nentries) |i| {
+        if (containsI(entries[i], pat)) {
+            matched[nmatched] = i;
+            nmatched += 1;
+        }
+    }
+    if (sel >= nmatched)
+        sel = if (nmatched > 0) nmatched - 1 else 0;
+    adjustOff();
+}
+
+fn adjustOff() void {
+    if (sel < off) off = sel;
+    if (sel >= off + cfg_lines) off = sel - cfg_lines + 1;
+}
+
+// ---- drawing ----
+
+fn textW(text: []const u8) i32 {
+    var ext: c.XGlyphInfo = undefined;
+    c.XftTextExtentsUtf8(dpy, xftfont, text.ptr, @intCast(text.len), &ext);
+    return ext.xOff;
+}
+
+fn drawStr(x: i32, y: i32, text: []const u8, color: [*c]const c.XftColor) void {
+    c.XftDrawStringUtf8(xftdraw, color, xftfont, x, y, text.ptr, @intCast(text.len));
+}
+
+fn drawRect(x: i32, y: i32, w: u32, h: u32, color: [*c]const c.XftColor) void {
+    c.XftDrawRect(xftdraw, color, x, y, w, h);
+}
+
+fn draw() void {
+    const w = cfg_width;
+    const h = winH();
+    const asc: i32 = xftfont.ascent;
+    const lh: i32 = line_h;
+    drawRect(0, 0, w, h, &xbg);
+    const pslice = std.mem.sliceTo(zptr(&sprompt), 0);
+    const pw = textW(pslice);
+    drawStr(pad, asc + 2, pslice, &xprompt);
+    const islice = input.slice();
+    const ix = pad + pw;
+    drawStr(ix, asc + 2, islice, &xfg);
+    const cx = ix + textW(islice);
+    drawRect(cx, 4, 2, @intCast(xftfont.height), &xfg);
+    const vis = @min(nmatched, cfg_lines);
+    for (0..vis) |i| {
+        const mi = off + i;
+        if (mi >= nmatched) break;
+        const my: i32 = @intCast((i + 1) * @as(usize, @intCast(lh)));
+        const entry = entries[matched[mi]];
+        if (mi == sel) {
+            drawRect(0, my, w, @intCast(lh), &xselbg);
+            drawStr(pad, my + asc + 2, entry, &xselfg);
+        } else {
+            drawStr(pad, my + asc + 2, entry, &xfg);
+        }
+    }
+    _ = c.XFlush(dpy);
+}
+
+// ---- spawn ----
+
+fn spawn(cmd: []const u8) void {
+    var child = std.process.Child.init(
+        &.{ "/bin/sh", "-c", cmd },
+        std.heap.page_allocator,
+    );
+    child.stdin_behavior = .Ignore;
+    child.stdout_behavior = .Ignore;
+    child.stderr_behavior = .Ignore;
+    _ = child.spawn() catch {};
+}
+
+fn cleanup() void {
+    c.XftFontClose(dpy, xftfont);
+    c.XftDrawDestroy(xftdraw);
+    _ = c.XDestroyWindow(dpy, win);
+    _ = c.XCloseDisplay(dpy);
+}
+
+fn launch(cmd: []const u8) noreturn {
+    spawn(cmd);
+    cleanup();
+    std.process.exit(0);
+}
+
+// ---- key handling ----
+
+fn handleKey(ev: *c.XKeyEvent) void {
+    var buf: [32]u8 = undefined;
+    var ks: c.KeySym = 0;
+    const n = c.XLookupString(ev, &buf, @intCast(buf.len), &ks, null);
+    if (ks == XK_Return) {
+        if (nmatched > 0 and sel < nmatched)
+            launch(entries[matched[sel]]);
+        cleanup();
+        std.process.exit(1);
+    }
+    if (ks == XK_Escape) {
+        cleanup();
+        std.process.exit(1);
+    }
+    if (ks == XK_BackSpace or ks == XK_Delete) {
+        if (input.pop()) |_| {
+            sel = 0;
+            matchEntries();
+            draw();
+        }
+        return;
+    }
+    if (ks == XK_Up) {
+        if (sel > 0) sel -= 1;
+        adjustOff();
+        draw();
+        return;
+    }
+    if (ks == XK_Down) {
+        if (sel + 1 < nmatched) sel += 1;
+        adjustOff();
+        draw();
+        return;
+    }
+    if (ks == XK_Home) {
+        sel = 0;
+        adjustOff();
+        draw();
+        return;
+    }
+    if (ks == XK_End) {
+        sel = if (nmatched > 0) nmatched - 1 else 0;
+        adjustOff();
+        draw();
+        return;
+    }
+    const ctrl = ev.state & c.ControlMask != 0;
+    if (n == 0 and ctrl and (ks == 'u' or ks == 'U')) {
+        input.resize(0) catch {};
+        sel = 0;
+        matchEntries();
+        draw();
+        return;
+    }
+    if (n == 0 and ctrl and (ks == 'n' or ks == 'N')) {
+        if (sel + 1 < nmatched) sel += 1;
+        adjustOff();
+        draw();
+        return;
+    }
+    if (n == 0 and ctrl and (ks == 'p' or ks == 'P')) {
+        if (sel > 0) sel -= 1;
+        adjustOff();
+        draw();
+        return;
+    }
+    if (n > 0 and buf[0] >= 0x20) {
+        for (buf[0..@as(usize, @intCast(n))]) |ch|
+            input.append(ch) catch {};
+        sel = 0;
+        off = 0;
+        matchEntries();
+        draw();
+    }
+}
+
+// ---- X11 init ----
+
+fn xinit() void {
+    dpy = c.XOpenDisplay(null) orelse die("cannot open display", .{});
+    const scr: c_int = 0;
+    const s = c.XScreenOfDisplay(dpy, scr) orelse die("no screen", .{});
+    const root = s.*.root;
+    const sw = s.*.width;
+    visual = s.*.root_visual orelse die("no visual", .{});
+    cmap = s.*.cmap;
+    const wh = winH();
+    const wx = @divTrunc(@as(c_int, @intCast(sw)) - @as(c_int, @intCast(cfg_width)), 2);
+    const wy: c_int = 0;
+    var swa: c.XSetWindowAttributes = std.mem.zeroes(c.XSetWindowAttributes);
+    swa.override_redirect = 1;
+    swa.event_mask = c.ExposureMask | c.KeyPressMask | c.StructureNotifyMask;
+    win = c.XCreateWindow(
+        dpy,
+        root,
+        wx,
+        wy,
+        cfg_width,
+        wh,
+        border_px,
+        c.CopyFromParent,
+        c.CopyFromParent,
+        visual,
+        c.CWOverrideRedirect | c.CWEventMask,
+        &swa,
+    );
+    xftdraw = c.XftDrawCreate(dpy, win, visual, cmap) orelse die("XftDrawCreate", .{});
+    xftfont = c.XftFontOpenName(dpy, scr, zptr(&sfont)) orelse
+        die("cannot load font: {s}", .{std.mem.sliceTo(zptr(&sfont), 0)});
+    xbg = xftColor(zptr(&sbg));
+    xfg = xftColor(zptr(&sfg));
+    xselbg = xftColor(zptr(&sselbg));
+    xselfg = xftColor(zptr(&sselfg));
+    xprompt = xftColor(zptr(&sbdc));
+    _ = c.XSetWindowBorder(dpy, win, xprompt.pixel);
+    _ = c.XSetWindowBorderWidth(dpy, win, border_px);
+    _ = c.XMapWindow(dpy, win);
+    _ = c.XSetInputFocus(dpy, win, c.RevertToParent, c.CurrentTime);
+    _ = c.XGrabKeyboard(dpy, win, 1, c.GrabModeAsync, c.GrabModeAsync, c.CurrentTime);
+    _ = c.XSync(dpy, 0);
+}
+
+// ---- event loop ----
+
+fn run() void {
+    var ev: c.XEvent = undefined;
+    while (true) {
+        _ = c.XNextEvent(dpy, &ev);
+        switch (ev.type) {
+            c.Expose => draw(),
+            c.KeyPress => handleKey(&ev.xkey),
+            c.DestroyNotify => return,
+            else => {},
+        }
+    }
+}
+
+// ---- main ----
+
+pub fn main() void {
+    _ = setZ(&sfont, dfont);
+    _ = setZ(&sprompt, dprompt);
+    _ = setZ(&sbg, dbg);
+    _ = setZ(&sfg, dfg);
+    _ = setZ(&sselbg, dselbg);
+    _ = setZ(&sselfg, dselfg);
+    _ = setZ(&sbdc, dbdc);
+    parseArgs();
+    readEntries();
+    xinit();
+    matchEntries();
+    draw();
+    run();
+}
diff --git a/srun b/srun
new file mode 160000
index 0000000..307f9cd
--- /dev/null
+++ b/srun
@@ -0,0 +1 @@
+Subproject commit 307f9cd8bd150efda9da8e3ea360ab273abf4963