ajhahn.de
← FlashOS
Flash 233 lines
// less — the full-screen text pager for /bin/less.
//
// The first consumer of the navigation scaffold's full-screen half: where
// sysinfo proved console_ui.screen's kv() renderer print-and-exit, less proves
// the interactive loop — it takes over the console with screen.enter(), reads
// keys through flibc.readKey()'s VT100 decoder, scrolls with the pure
// flibc.Pager core, and restores the shell view with screen.leave() on every
// exit path. Output is screen.panelTop for the title bar plus raw content rows;
// input is the arrow / page / quit keys a pager needs.
//
// Scope is a proof, like sysinfo: it pages a single named file, slurps up to
// BUF_MAX bytes onto its own stack (rule 1 — no heap, no .bss), indexes the
// first MAX_LINES lines, and assumes a 24x80 serial terminal (no window-size
// ioctl exists yet). A file larger than the slurp shows a "(more)" marker.
// Reading a pipe is out of scope: fd 0 is the key source, so `cmd | less` would
// have nowhere to read keys from (a /dev/tty concern for later).
//
// Same coreutil recipe as ls / sysinfo: flibc _start shim, flibc_mem, single
// R+X PT_LOAD, stack buffers only. Kept out of the CI FSH_SCRIPT — it is
// interactive and the free-page baseline must stay deterministic.
//
// Alignment note: under SCTLR_EL1.A strict-align, a generic build target would
// vectorize byte copies and materialize >16-byte by-value struct returns with a
// 16-byte `str q`, which faults on an only-8-aligned slot. So the Pager (a
// >16-byte value) is returned into an `align(16)` slot, and the one string
// concat (the title) writes through a volatile pointer so it is never widened.
// Everything else emits source slices straight to the sink — no copies.

use flibc
use console_ui

link "flibc_start"
link "flibc_mem"

// Assumed serial-terminal geometry. One header row (panelTop), one status row,
// the rest content. No window-size query exists, so these are fixed.
const ROWS usize = 24
const STATUS usize = 1
const HEADER usize = 1
const PAGE usize = ROWS - HEADER - STATUS // visible content rows
const COLS usize = 80 // clip width — keep each rendered row to one line

const BUF_MAX usize = 16384 // file slurp cap (on this frame)
const MAX_LINES usize = 2048 // line-index slots
const TITLE_MAX usize = COLS // "less: <name>" scratch

fn sink(bytes []u8) void {
    _ = flibc.sys.write_fd(1, bytes.ptr, bytes.len)
}

export fn main(argc usize, argv argv) noreturn {
    if argc < 2 || argv[1] == null {
        sink("usage: less <file>\n")
        flibc.exit()
    }
    path := argv[1].?

    fd := flibc.sys.open(path)
    if fd < 0 {
        sink("less: cannot open file\n")
        flibc.exit()
    }

    // Slurp up to BUF_MAX bytes; `truncated` if the file filled the buffer (it
    // may hold more — best-effort, this is a proof pager).
    var buf [BUF_MAX]u8 = undefined
    var n usize = 0
    while n < buf.len {
        r := flibc.sys.read(fd, buf[n..].ptr, buf.len - n)
        if r <= 0 {
            break
        }
        n += #intCast(r)
    }
    _ = flibc.sys.close(fd)
    truncated := (n == buf.len)

    // Title bar text, built once (the only string copy — volatile dest so the
    // strict-align target cannot vectorize it into a faulting `str q`).
    var title_buf [TITLE_MAX]u8 = undefined
    title := buildTitle(&title_buf, baseName(path))

    // Pager value is >16 bytes; land the by-value return on a 16-aligned slot so
    // its sret store is not a misaligned `str q`.
    var slots [MAX_LINES]u32 = undefined
    var pg flibc.Pager align(16) = flibc.Pager.init(buf[0..n], &slots, PAGE)

    // Take over the console: echo off (mode 0) so typed keys do not leak onto
    // the alt-screen, then the alternate buffer + hidden cursor.
    _ = flibc.sys.set_console_mode(0)
    console_ui.screen.enter(sink)
    render(&pg, title, truncated)

    while true {
        ev := flibc.readKey()
        var quit bool = false
        switch ev.key {
            .eof, .escape, .ctrl_c, .ctrl_d => { quit = true },
            .up => pg.up(1),
            .down, .enter => pg.down(1),
            .char => switch ev.ch {
                'q' => { quit = true },
                'j' => pg.down(1),
                'k' => pg.up(1),
                ' ', 'f' => pg.pageDown(),
                'b' => pg.pageUp(),
                'g' => pg.toTop(),
                'G' => pg.toBottom(),
                else => {},
            },
            else => {}, // left/right/tab/backspace/none — ignored
        }
        if quit {
            break
        }
        render(&pg, title, truncated)
    }

    // Every exit path restores the shell view. The console is left in mode 0
    // (echo off) — the shell's own baseline, where readline does its own echo —
    // so there is deliberately no mode restore here (mode 1 would double-echo
    // the next prompt); fsh also re-asserts mode 0 after wait() as a backstop.
    console_ui.screen.leave(sink)
    flibc.exit()
}

// Repaint the whole screen: title bar, PAGE content rows (clipped to COLS, '~'
// past EOF), then the status row. Full clear + repaint each frame keeps the
// renderer trivial — fine for a serial console.
fn render(pg *flibc.Pager, title []u8, truncated bool) void {
    console_ui.screen.clear(sink) // home + erase
    console_ui.screen.panelTop(sink, .{ .title = title, .width = COLS })

    var row usize = 0
    while row < pg.rows {
        idx := pg.top + row
        if idx < pg.n {
            l := pg.line(idx)
            sink(if (l.len <= COLS) l else l[0..COLS])
        } else {
            sink("~")
        }
        sink("\n")
        row += 1
    }

    statusLine(pg, truncated)
}

// Position + key legend on the final row. No trailing newline so the alt-screen
// does not scroll. The filename already rides the title bar.
fn statusLine(pg *flibc.Pager, truncated bool) void {
    shown := if (pg.n > pg.top) #min(pg.rows, pg.n - pg.top) else 0
    const first u64 = if (pg.n == 0) 0 else pg.top + 1
    const last u64 = pg.top + shown
    sink(" ")
    emitDec(first)
    sink("-")
    emitDec(last)
    sink("/")
    emitDec(pg.n)
    if truncated {
        sink(" (more)")
    }
    sink("   q=quit  space=page  b=back  g/G=ends")
}

// Last path component, as a slice into the argv string (no copy). "/a/b" -> "b",
// "x" -> "x".
fn baseName(path cstr) []u8 {
    var len usize = 0
    while path[len] != 0 {
        len += 1
    }
    var start usize = 0
    var i usize = 0
    while i < len {
        if path[i] == '/' {
            start = i + 1
        }
        i += 1
    }
    return path[start..len]
}

// "less: <name>" into `buf`, clipped to its length. The destination is volatile
// so the strict-align target never widens the byte stores into a `str q`.
fn buildTitle(buf []mut u8, name []u8) []u8 {
    const dst [*]mut volatile u8 = buf.ptr
    var i usize = 0
    prefix := "less: "
    for c in prefix {
        dst[i] = c
        i += 1
    }
    for c in name {
        if i >= buf.len {
            break
        }
        dst[i] = c
        i += 1
    }
    return buf[0..i]
}

// Emit `v` as decimal ASCII (mirrors sysinfo's u64dec — a proven small reversal
// that the strict-align target does not vectorize).
fn emitDec(v u64) void {
    var buf [20]u8 = undefined
    sink(buf[0..u64dec(&buf, v)])
}

fn u64dec(out []mut u8, v u64) usize {
    if v == 0 {
        out[0] = '0'
        return 1
    }
    var tmp [20]u8 = undefined
    var n usize = 0
    var x u64 = v
    while x != 0 {
        tmp[n] = '0' + #as(u8, #intCast(x % 10))
        n += 1
        x /= 10
    }
    var i usize = 0
    while i < n {
        out[i] = tmp[n - 1 - i]
        i += 1
    }
    return n
}