ajhahn.de
← FlashOS
Flash 260 lines
// flibc key decoder — raw console bytes → semantic Key events.
//
// The input half of FlashOS's shell-first navigation (the full-screen tools).
// A full-screen tool puts the console in mode 0 (raw: kernel echo off,
// byte-at-a-time — the same mode /bin/login's password loop relies on) and
// calls readKey() in a loop until it returns .eof. The pure Decoder turns a
// byte stream — including the multi-byte ESC-[ A/B/C/D arrow sequences — into
// Key values and is host-tested; the SVC-driven readKey() driver is gated
// behind has_driver exactly like readline's, so the host build never analyses
// inline asm.
//
// No allocator, no module state beyond the caller-held Decoder. Zero footprint
// until referenced — no boot binary calls readKey yet (the first consumer,
// /bin/mon, lands with the goal.md §4 hardware monitor).

const builtin = #import("builtin")

// Driver compiles only on aarch64-freestanding (the real flibc target); the
// host-test build flips this off so the SVC trampoline never enters semantic
// analysis. Only the pure Decoder is exercised on host.
const has_driver = builtin.cpu.arch == .aarch64 && builtin.target.os.tag == .freestanding

/// A decoded key. `.char` carries its byte in Event.ch; `.none` means the byte
/// was consumed mid-escape-sequence (feed more); `.eof` means the stream closed.
/// The navigation set (delete / home / end / page_up / page_down) and the editor
/// command chords (ctrl_o / ctrl_w / ctrl_x) land here for /bin/edit; a pager or
/// readline that does not use them lets them fall through its switch default.
pub const Key = enum {
    up,
    down,
    left,
    right,
    enter,
    backspace,
    delete,
    tab,
    escape,
    home,
    end,
    page_up,
    page_down,
    ctrl_c,
    ctrl_d,
    ctrl_o,
    ctrl_w,
    ctrl_x,
    char,
    none,
    eof,
}

/// A key event. `ch` is meaningful only for `.char`.
pub const Event = struct {
    key Key,
    ch u8 = 0,
}

/// Incremental VT100 input decoder. Feed it one byte at a time; it returns
/// `.none` while inside an ESC sequence and a real key when one completes. A
/// fresh Decoder per readKey() call is correct — a whole sequence is consumed
/// within one call.
pub const Decoder = struct {
    state State = .ground,
    param u16 = 0, // accumulates the CSI numeric parameter (ESC[<n>~)

    const State = enum { ground, esc, csi }

    pub fn feed(self *mut Decoder, b u8) Event {
        return switch self.state {
            .ground => self.atGround(b),
            .esc => self.atEsc(b),
            .csi => self.atCsi(b),
        }
    }

    fn atGround(self *mut Decoder, b u8) Event {
        return switch b {
            0x1b => blk: {
                self.state = .esc
                break :blk Event{ .key = .none }
            },
            '\r', '\n' => .{ .key = .enter },
            '\t' => .{ .key = .tab },
            0x08, 0x7f => .{ .key = .backspace },
            0x03 => .{ .key = .ctrl_c },
            0x04 => .{ .key = .ctrl_d },
            0x0f => .{ .key = .ctrl_o },
            0x17 => .{ .key = .ctrl_w },
            0x18 => .{ .key = .ctrl_x },
            0x20...0x7e => .{ .key = .char, .ch = b },
            else => .{ .key = .none },
        }
    }

    fn atEsc(self *mut Decoder, b u8) Event {
        if b == '[' {
            self.state = .csi
            self.param = 0
            return .{ .key = .none }
        }
        if b == 0x1b {
            // A second ESC — stay pending on the newer one.
            return .{ .key = .none }
        }
        // ESC then anything else: a bare Escape; the trailing byte is dropped
        // (Alt-<key> chords are out of scope for v1).
        self.state = .ground
        return .{ .key = .escape }
    }

    fn atCsi(self *mut Decoder, b u8) Event {
        // Digits accumulate the parameter so ESC[3~ (Delete), ESC[5~ (PgUp) etc.
        // are distinguished rather than collapsed. A ';' starts a sub-parameter
        // (e.g. ESC[1;5C modified arrows); reset so the final group wins — the
        // arrow/tilde keys ignore modifiers here.
        if b >= '0' && b <= '9' {
            self.param = self.param * 10 + #as(u16, b - '0')
            return .{ .key = .none }
        }
        if b == ';' {
            self.param = 0
            return .{ .key = .none }
        }
        self.state = .ground
        return switch b {
            'A' => .{ .key = .up },
            'B' => .{ .key = .down },
            'C' => .{ .key = .right },
            'D' => .{ .key = .left },
            'H' => .{ .key = .home }, // ESC[H — Home (no parameter form)
            'F' => .{ .key = .end }, // ESC[F — End
            '~' => switch self.param {
                1, 7 => .{ .key = .home }, // ESC[1~ / ESC[7~
                3 => .{ .key = .delete }, // ESC[3~
                4, 8 => .{ .key = .end }, // ESC[4~ / ESC[8~
                5 => .{ .key = .page_up }, // ESC[5~
                6 => .{ .key = .page_down }, // ESC[6~
                else => .{ .key = .none },
            },
            else => .{ .key = .none },
        }
    }
}

/// Block until one whole key is read from fd 0. Returns `.eof` when the stream
/// closes. Use inside a full-screen loop; pair with console_ui.screen.enter /
/// leave and console mode 0.
pub const readKey = driver.readKey

const driver = if (has_driver) struct {
    const sys = #import("syscalls.zig")

    pub fn readKey() Event {
        var dec = Decoder{}
        var b u8 = 0
        while true {
            const n = sys.read(0, #ptrCast(&b), 1)
            if n <= 0 {
                return .{ .key = .eof }
            }
            const ev = dec.feed(b)
            if ev.key != .none {
                return ev
            }
        }
    }
} else struct {
    // Host-test stub: present only so the `pub const readKey` binding succeeds.
    pub fn readKey() Event {
        return .{ .key = .eof }
    }
}

// ---- host tests ------------------------------------------------------------

const std = #import("std")
const testing = std.testing

fn decodeOne(seq []u8) Event {
    var d = Decoder{}
    var last Event = .{ .key = .none }
    for b in seq {
        last = d.feed(b)
        if last.key != .none {
            return last
        }
    }
    return last
}

test "printable byte decodes to char" {
    const e = decodeOne("a")
    try testing.expectEqual(Key.char, e.key)
    try testing.expectEqual(#as(u8, 'a'), e.ch)
}

test "CR and LF decode to enter" {
    try testing.expectEqual(Key.enter, decodeOne("\r").key)
    try testing.expectEqual(Key.enter, decodeOne("\n").key)
}

test "tab decodes to tab" {
    try testing.expectEqual(Key.tab, decodeOne("\t").key)
}

test "ctrl-c and ctrl-d" {
    try testing.expectEqual(Key.ctrl_c, decodeOne(&.{0x03}).key)
    try testing.expectEqual(Key.ctrl_d, decodeOne(&.{0x04}).key)
}

test "arrow sequences decode through ESC [ A..D" {
    try testing.expectEqual(Key.up, decodeOne("\x1b[A").key)
    try testing.expectEqual(Key.down, decodeOne("\x1b[B").key)
    try testing.expectEqual(Key.right, decodeOne("\x1b[C").key)
    try testing.expectEqual(Key.left, decodeOne("\x1b[D").key)
}

test "parametrized CSI (ESC[5~) decodes on the terminator, not before" {
    var d = Decoder{}
    try testing.expectEqual(Key.none, d.feed(0x1b).key)
    try testing.expectEqual(Key.none, d.feed('[').key)
    try testing.expectEqual(Key.none, d.feed('5').key) // parameter byte buffered
    try testing.expectEqual(Key.page_up, d.feed('~').key) // terminator yields the key
}

test "bare ESC then a letter yields escape" {
    var d = Decoder{}
    try testing.expectEqual(Key.none, d.feed(0x1b).key)
    try testing.expectEqual(Key.escape, d.feed('x').key)
}

test "editor command chords decode at ground" {
    try testing.expectEqual(Key.ctrl_o, decodeOne(&.{0x0f}).key)
    try testing.expectEqual(Key.ctrl_w, decodeOne(&.{0x17}).key)
    try testing.expectEqual(Key.ctrl_x, decodeOne(&.{0x18}).key)
}

test "tilde navigation sequences decode by parameter" {
    try testing.expectEqual(Key.home, decodeOne("\x1b[1~").key)
    try testing.expectEqual(Key.delete, decodeOne("\x1b[3~").key)
    try testing.expectEqual(Key.end, decodeOne("\x1b[4~").key)
    try testing.expectEqual(Key.page_up, decodeOne("\x1b[5~").key)
    try testing.expectEqual(Key.page_down, decodeOne("\x1b[6~").key)
    try testing.expectEqual(Key.home, decodeOne("\x1b[7~").key)
    try testing.expectEqual(Key.end, decodeOne("\x1b[8~").key)
}

test "letter Home and End decode (ESC[H / ESC[F)" {
    try testing.expectEqual(Key.home, decodeOne("\x1b[H").key)
    try testing.expectEqual(Key.end, decodeOne("\x1b[F").key)
}

test "modified arrow (ESC[1;5C) still decodes to a plain arrow" {
    try testing.expectEqual(Key.right, decodeOne("\x1b[1;5C").key)
}

test "an unknown tilde parameter is absorbed, not leaked" {
    try testing.expectEqual(Key.none, decodeOne("\x1b[99~").key)
}