ajhahn.de
← FlashOS
Flash 172 lines
// console_ui — FlashOS shared terminal look.
//
// One module, compiled into every binary that draws to the console: the kernel
// boot log and the userspace tools (fsh, login, dmesg, …). Editing this module
// restyles the whole system on the next build — there is no second copy of a
// bracket tag or an ANSI code anywhere else in the tree.
//
// Layout: the look is split by concern so it scales as the UI grows, but it
// stays a single import — consumers only ever `@import("console_ui")`.
//   * palette.flash — the `color` knob + the ANSI palette
//   * tags.flash    — the `Level` severity taxonomy + each level's `Tag`
//   * this file     — the `Sink`, the renderers, the `Logger`, and the
//                     homescreen; it re-exports the two above so a consumer
//                     reaches the whole surface through one name.
//
// Freestanding by construction: no allocator, no std, no dependency on kernel
// internals or flibc. Output is routed through a caller-supplied `Sink`, so the
// same renderers serve the kernel (main_output) and userspace (write(2)) with
// neither side leaking in. Because it is pure and target-agnostic, each
// consumer recompiles it with its own settings.

pub use "palette" as palette
pub use "tags" as tags
pub use "screen" as screen

// ---- public surface (flat re-exports) --------------------------------------
// The hot names a consumer reaches for, lifted to the top level so call sites
// read `console_ui.ok` / `console_ui.color` rather than digging through a
// sub-namespace. The full palette + taxonomy stay reachable as `console_ui.
// palette.*` / `console_ui.tags.*`.
pub const color = palette.color
pub const Level = tags.Level
pub const Tag = tags.Tag
pub const ok = tags.ok
pub const info = tags.info
pub const load = tags.load
pub const warn = tags.warn
pub const fail = tags.fail
pub const skip = tags.skip

/// A byte sink. Each consumer binds it to its own console writer:
///   kernel -> a byte loop over main_output_char(MU, b)
///   user   -> write(1, bytes.ptr, bytes.len)
pub const Sink = *fn([]u8) void

/// Box-drawing charset for the screen-layer panels — single-sourced in
/// palette.flash and re-exported here so call sites keep reading
/// `console_ui.unicode`. false = ASCII (+-|), true = Unicode.
pub const unicode bool = palette.unicode

/// Boot-success marker — the homescreen tail. Frozen: scripts/run_qemu_test.sh
/// greps this literal (x3 per boot) as the boot pass signal. Single source of
/// truth — do not reword without updating the contract header in
/// scripts/run_qemu_test.sh.
pub const marker_ready = " - type 'help' for commands"

// ---- renderers -------------------------------------------------------------

/// Write a tag as `<pre><word><post>` with the brackets + padding in the
/// default color and only `word` tinted by `t.ansi`. Color off => both ANSI
/// strings are empty and the bytes are the plain six-wide `[ OK ]` form.
fn writeTag(sink Sink, t Tag) void {
    sink(t.pre)
    sink(t.ansi)
    sink(t.word)
    sink(palette.reset)
    sink(t.post)
}

/// Write a tag followed by a single space, with no message and no newline — the
/// seam for a line whose tail is assembled by the caller (e.g. a boot line that
/// interleaves dynamic digits).
pub fn tagged(sink Sink, t Tag) void {
    writeTag(sink, t)
    sink(" ")
}

/// Write one finished tagged line: `<tag> <msg>\n`, the tag colored when
/// enabled.
pub fn line(sink Sink, t Tag, msg []u8) void {
    tagged(sink, t)
    sink(msg)
    sink("\n")
}

/// A pending stage that resolves in place. `stage()` prints `[LOAD] <msg>` with
/// no newline; a later `.done()` / `.failed()` carriage-returns to column 0 and
/// overwrites the tag, then ends the line. Same width + same message text means
/// the overwrite is exact even with color on (the escapes are zero-width).
pub const Stage = struct {
    sink Sink,
    msg []u8,

    /// Flip the pending tag to green [ OK ] and finish the line.
    pub fn done(self Stage) void {
        self.resolve(ok)
    }

    /// Flip the pending tag to red [FAIL] and finish the line.
    pub fn failed(self Stage) void {
        self.resolve(fail)
    }

    fn resolve(self Stage, t Tag) void {
        self.sink("\r")
        line(self.sink, t, self.msg)
    }
}

/// Begin a pending stage: prints `[LOAD] <msg>` (no newline yet). Resolve it
/// with `.done()` or `.failed()`.
pub fn stage(sink Sink, msg []u8) Stage {
    tagged(sink, load)
    sink(msg)
    return .{ .sink = sink, .msg = msg }
}

/// A plain banner / homescreen line (text + newline). Placeholder seam for the
/// richer panel + key/value renderers, which land when a screen needs them.
pub fn banner(sink Sink, text []u8) void {
    sink(text)
    sink("\n")
}

/// A `Sink` bound once, so a consumer logs `log.ok("…")` instead of repeating
/// the sink at every call. Pure sugar over the free `line` renderer — the look
/// is unchanged. The free renderers stay available for one-off and assembled
/// lines.
pub const Logger = struct {
    sink Sink,

    pub fn ok(self Logger, msg []u8) void {
        line(self.sink, tags.ok, msg)
    }
    pub fn info(self Logger, msg []u8) void {
        line(self.sink, tags.info, msg)
    }
    pub fn warn(self Logger, msg []u8) void {
        line(self.sink, tags.warn, msg)
    }
    pub fn fail(self Logger, msg []u8) void {
        line(self.sink, tags.fail, msg)
    }
    pub fn skip(self Logger, msg []u8) void {
        line(self.sink, tags.skip, msg)
    }
    /// Log at a runtime-chosen level.
    pub fn status(self Logger, level Level, msg []u8) void {
        line(self.sink, tags.of(level), msg)
    }
}

/// Bind a `Sink` into a `Logger`.
pub fn logger(sink Sink) Logger {
    return .{ .sink = sink }
}

/// FlashOS shell homescreen: `FlashOS [v<version>] by <author> - type 'help'
/// for commands`, followed by a blank line. `version` and `author` are passed
/// in (this module is freestanding) — fsh feeds `build_options.version`, itself
/// sourced from build.zig.zon, so the release version lives in exactly one
/// place. The `type 'help' for commands` tail is the frozen boot-success marker
/// (run_qemu_test.sh greps it x3) — keep it byte-for-byte.
pub fn homescreen(sink Sink, version []u8, author []u8) void {
    sink("FlashOS [v")
    sink(version)
    sink("] by ")
    sink(author)
    sink(marker_ready)
    sink("\n\n")
}