Flash 258 lines
// console_ui screen layer — full-screen panels for TUI navigation.
//
// The output half of FlashOS's shell-first navigation: the shell is the
// primary interface and only specific tools take over the whole screen. A
// full-screen tool — the coming /bin/mon hardware monitor, a pager, a
// log viewer — takes over the console with enter(), paints panels
// (panelTop/panelRow/panelBottom) and metric rows (kv), reads keys via
// flibc.readKey, and restores the shell view with leave() when the user quits.
//
// Pure + freestanding, exactly like palette.flash / tags.flash: every renderer
// takes a caller-supplied Sink and emits bytes — no allocator, no module-level
// state, no dependency on the kernel or flibc. All control bytes are plain
// ANSI/VT100 over the serial console; there is no framebuffer (the project goal
// is a text workstation "ohne grafischen Overhead"). Box glyphs follow the
// shared palette.unicode charset knob (ASCII by default — the UART console
// passes raw bytes and only UTF-8 terminals render the Unicode forms).
//
// Zero footprint until referenced: like every console_ui decl, these are
// analyzed only when a call site names them, so staging the file leaves the
// kernel + fsh images byte-identical until the first consumer (/bin/mon) lands.
use "palette" as palette
/// Byte sink — structurally identical to console_ui.Sink (Zig fn-pointer types
/// are structural), so a consumer threads one sink through the line renderers
/// and these screen renderers alike.
pub const Sink = *fn([]u8) void
// ---- alternate-screen lifecycle --------------------------------------------
/// Enter full-screen: switch to the alternate screen buffer, hide the cursor,
/// home it, and clear. Pairs with leave(); the \e[?1049h alt buffer leaves the
/// shell's scrollback untouched so it reappears verbatim on leave().
pub fn enter(sink Sink) void {
sink("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J")
}
/// Leave full-screen: restore the cursor and the main screen buffer. A
/// full-screen tool MUST call this on every exit path; fsh also resets the
/// console after each wait() as a backstop.
pub fn leave(sink Sink) void {
sink("\x1b[?25h\x1b[?1049l")
}
/// Clear the screen and home the cursor without touching the buffer stack.
pub fn clear(sink Sink) void {
sink("\x1b[H\x1b[2J")
}
/// Move the cursor to (row, col), 1-based — \e[<row>;<col>H.
pub fn moveTo(sink Sink, row u16, col u16) void {
var buf [16]u8 = undefined
var i usize = 0
buf[i] = 0x1b
i += 1
buf[i] = '['
i += 1
i += writeDec(buf[i..], row)
buf[i] = ';'
i += 1
i += writeDec(buf[i..], col)
buf[i] = 'H'
i += 1
sink(buf[0..i])
}
// ---- panels ----------------------------------------------------------------
// Box-drawing charset, chosen at comptime from palette.unicode. ASCII default
// keeps a dumb-terminal capture legible; the Unicode forms render on UTF-8.
const glyph = if (palette.unicode) struct {
const tl = "\u{250c}"
const tr = "\u{2510}"
const bl = "\u{2514}"
const br = "\u{2518}"
const h = "\u{2500}"
const v = "\u{2502}"
} else struct {
const tl = "+"
const tr = "+"
const bl = "+"
const br = "+"
const h = "-"
const v = "|"
}
/// A bordered panel. `width` is the total column count including both borders;
/// the inner content width is `width - 2`. The caller positions the cursor
/// (moveTo) before each row for a full-screen layout, or just emits the rows
/// inline.
pub const Panel = struct {
title []u8,
width u16,
fn inner(self Panel) usize {
return if (self.width >= 2) self.width - 2 else 0
}
}
/// Top border carrying the title: `+- title -----------+` — rendered only when
/// the inner width has room for "- <title> "; otherwise a plain filled border.
pub fn panelTop(sink Sink, p Panel) void {
const inw = p.inner()
sink(glyph.tl)
var used usize = 0
if inw >= p.title.len + 3 {
sink(glyph.h)
sink(" ")
sink(p.title)
sink(" ")
used = p.title.len + 3
}
repeat(sink, glyph.h, inw - used)
sink(glyph.tr)
sink("\n")
}
/// A content row: `| text<pad> |`, text clipped / space-padded to the inner
/// width minus the one-space gutter on each side.
pub fn panelRow(sink Sink, p Panel, text []u8) void {
const inw = p.inner()
sink(glyph.v)
if inw >= 2 {
sink(" ")
const room = inw - 2
const t = if (text.len <= room) text else text[0..room]
sink(t)
repeat(sink, " ", room - t.len)
sink(" ")
} else {
repeat(sink, " ", inw)
}
sink(glyph.v)
sink("\n")
}
/// Bottom border: `+-------------------+`.
pub fn panelBottom(sink Sink, p Panel) void {
sink(glyph.bl)
repeat(sink, glyph.h, p.inner())
sink(glyph.br)
sink("\n")
}
// ---- key/value rows --------------------------------------------------------
/// Column the value starts at in a kv() row. Eight fits "CPU"/"MEM"/"UP"/"USER"
/// with a margin; a longer key gets a single trailing space instead.
pub const kv_col usize = 8
/// A "key value" metric row + newline — the renderer sysinfo and /bin/mon
/// use for each line. The key is padded to kv_col; an over-long key falls back
/// to a single space so the value never collides.
pub fn kv(sink Sink, key []u8, value []u8) void {
sink(key)
const pad = if (key.len < kv_col) kv_col - key.len else 1
repeat(sink, " ", pad)
sink(value)
sink("\n")
}
// ---- helpers ---------------------------------------------------------------
/// Emit `s` `n` times.
fn repeat(sink Sink, s []u8, n usize) void {
var i usize = 0
while i < n {
sink(s)
i += 1
}
}
/// Write `v` as decimal ASCII into `out` (>= 5 bytes), returning the count.
fn writeDec(out []mut u8, v u16) usize {
if v == 0 {
out[0] = '0'
return 1
}
var tmp [5]u8 = undefined
var n usize = 0
var x = 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
}
// ---- host tests ------------------------------------------------------------
const std = #import("std")
const testing = std.testing
// Capturing sink for tests: appends to a fixed buffer. Host tests run
// single-threaded, so a module-global is fine here.
var cap_buf [512]u8 = undefined
var cap_len usize = 0
fn capSink(bytes []u8) void {
for b in bytes {
if cap_len < cap_buf.len {
cap_buf[cap_len] = b
cap_len += 1
}
}
}
fn capReset() void {
cap_len = 0
}
fn captured() []u8 {
return cap_buf[0..cap_len]
}
test "kv pads a short key to kv_col" {
capReset()
kv(capSink, "CPU", "1.50 GHz")
try testing.expectEqualStrings("CPU 1.50 GHz\n", captured())
}
test "kv falls back to a single space for an over-long key" {
capReset()
kv(capSink, "LONGKEYNAME", "v")
try testing.expectEqualStrings("LONGKEYNAME v\n", captured())
}
test "moveTo emits a 1-based CUP sequence" {
capReset()
moveTo(capSink, 3, 12)
try testing.expectEqualStrings("\x1b[3;12H", captured())
}
test "panelBottom width math (ASCII default)" {
capReset()
panelBottom(capSink, .{ .title = "x", .width = 6 })
// width 6 => 4 inner '-' between the corners
try testing.expectEqualStrings("+----+\n", captured())
}
test "panelRow clips and pads to the inner width" {
capReset()
panelRow(capSink, .{ .title = "t", .width = 8 }, "ab")
// inner 6, gutter 1 each side, room 4 => "ab" + 2 pad
try testing.expectEqualStrings("| ab |\n", captured())
}
test "writeDec handles zero and the u16 max" {
var b [5]u8 = undefined
try testing.expectEqual(#as(usize, 1), writeDec(&b, 0))
try testing.expectEqual(#as(u8, '0'), b[0])
const n = writeDec(&b, 65535)
try testing.expectEqualStrings("65535", b[0..n])
}