/* StarForth — Steady-State Virtual Machine Runtime Copyright (c) 2023–2025 Robert A. James All rights reserved. Licensed under the StarForth License, Version 1.0. */ /** * vt100.c — Full-color ANSI / VT100 terminal state machine * * Parser states * ───────────── * NORMAL plain text characters * ESC received 0x1B, waiting for next byte * CSI received ESC [ , accumulating parameter digits / intermediates * ESC_HASH received ESC # (line attributes — we ignore, eat next byte) * ESC_CHAR received ESC ( or ESC ) (charset designation — ignored) * * Supported CSI sequences * ─────────────────────── * CUU A cursor up * CUD B cursor down * CUF C cursor forward * CUB D cursor back * CUP H cursor position (row ; col) * ED J erase display (0=below 1=above 2=all) * EL K erase line (0=right 1=left 2=all) * SGR m select graphic rendition (see header) * SCP s save cursor position * RCP u restore cursor position * * ESC sequences (2-char) * ESC 7 save cursor (DEC) * ESC 8 restore cursor (DEC) * ESC M reverse index (scroll down) * ESC c full reset */ #include "vt100.h" #include "framebuffer.h" #include "starkernel/ttf.h" #include "starkernel/capsule_generated.h" #include "starkernel/kmalloc.h" #include "log.h" #include #include /* Maximum CSI parameters */ #define VT100_MAX_PARAMS 16 /* Underline scanline (row 14 of 16) -- font_8x16 mode only; see * draw_cursor_glyph()'s mode split. */ #define UNDERLINE_ROW 14u /* ----------------------------------------------------------------------- * FABRIC.md item 4.4j: TTF glyph backend, active only from * vt100_enable_ttf() onward (boot/POST stays font_8x16.c). * * Point size and cell geometry, decided final by item 4.4m (20px text, * 96px REPL strip = 4 lines). Chosen so 4.4i's confirmed hmtx result (all * printable ASCII share one 600/1000-em advance width) divides evenly: * 600/1000 * 20 = 12px exactly, no fractional-pixel cell width. Cell * height is size + a fixed descender/leading allowance, not derived from * hhea (not read by this parser -- see ttf.h). * --------------------------------------------------------------------- */ #define VT100_TTF_SIZE_PX 20u #define VT100_TTF_CELL_W_PX 12u /* 600/1000 * VT100_TTF_SIZE_PX */ #define VT100_TTF_CELL_H_PX 24u /* provisional: size + 4px leading/descender */ #define VT100_TTF_CACHE_SLOTS 96u /* covers all 95 printable ASCII, no thrash */ #define VT100_TTF_FONT_CAPSULE "fonts:JetBrainsMono-Regular.ttf" typedef enum { VT_GLYPH_BITMAP = 0, /* font_8x16.c, via fb_draw_glyph() */ VT_GLYPH_TTF = 1, /* TTF-TEXT's rasterizer, via ttf_draw_glyph_cell() */ } vt_glyph_mode_t; static vt_glyph_mode_t g_glyph_mode = VT_GLYPH_BITMAP; static ttf_font_t g_ttf_font; static int g_ttf_ready = 0; static ttf_raster_cache_t g_ttf_cache; /* FABRIC.md item 4.4y-revised: Alt+TAB graphics/text toggle. When 0, every * terminal draw call (put_char's glyph blit, erase, scroll) becomes a * pure logical update -- g_vt's cursor/attributes and the scrollback * shadow still advance normally, only the actual framebuffer write is * skipped -- so direct framebuffer/TTF-TEXT drawing shows through * undisturbed, and vt100_toggle_graphics() can restore the terminal * exactly as it logically stands once toggled back. Always 1 in bitmap * mode (boot/POST never hides). */ static int g_terminal_visible = 1; /* ----------------------------------------------------------------------- * FABRIC.md items 4.4t/4.4o/4.4u/4.4w/4.4x/4.4z tried a 640x480 CANVAS * box with a separate single-line REPL strip pinned below it -- a * confined region plus independent border-drawing/geometry bookkeeping * for both. Reverted 2026-08-12, simplified back to what this comment * block now describes: the entire framebuffer is one vt100 terminal, * full width and full height, in both bitmap and TTF glyph modes. No * origin offset, no box, no strip, no border drawing -- cols/rows are * simply fb_width()/cell_w() and fb_height()/cell_h(). The REPL prompt is * just the terminal's last line, exactly like any other scrolling * terminal, not a separately-rendered region. * --------------------------------------------------------------------- */ /* ----------------------------------------------------------------------- * FABRIC.md item 4.4q: REPL scrollback. Text-only (not pixel snapshots -- * at 1000 lines x ~cols bytes this is tens of KB; a pixel-snapshot ring * would be roughly three orders of magnitude larger for no benefit, since * the only consumer is a redraw). Only active in TTF mode (allocated in * vt100_enable_ttf(), never during boot/POST) -- font_8x16-mode boot * output has no scrollback and none is scoped for it. * * Scope, decided with Captain Bob 2026-08-11: this is scrollback on * today's full-screen terminal grid, NOT scoped to confine REPL text to * the 4.4o 640x480 box -- that confinement (vt100's grid still spans the * whole screen, not just the box) is a real, separate gap, observed by * 4.4p and still open; deliberately not fixed here, per that explicit * scoping decision rather than a silent deferral. * * Model: `g_shadow` mirrors exactly what's currently on screen (rows x * cols characters, updated by every draw_cursor_glyph() call and shifted * by every scroll_up()); `g_ring` holds every line scroll_up() has ever * evicted from the top of `g_shadow`, oldest-first, circular once full. * Conceptually one continuous history: ring (oldest..newest evicted) then * shadow (oldest..newest on-screen) back-to-back; g_scroll_offset is how * many lines back from the live (shadow) tail the current view sits. * Scrolling is always "pick a window into that history and redraw it" -- * no separate pixel-scroll code path for scrollback, so recall is * trivially consistent with what was actually printed instead of a second * mechanism bolted onto fb_scroll_rows(). * * Known limitations, not fixed here: redraw uses the terminal's current * default fg/bg for every recalled line -- per-cell historical SGR color * isn't stored, so scrollback recovers text exactly but not color. * Drawing new live output while g_scroll_offset > 0 is unverified and not * specially handled -- draw_cursor_glyph() always draws at the live * cursor position regardless of scroll offset, so it would visually * corrupt a scrolled-back view; out of scope for this item's verification * path (sequential FORTH-word injection over serial, never concurrent * with a live typist -- that's M8's keyboard-input problem, unstarted). * --------------------------------------------------------------------- */ #define VT100_SCROLLBACK_MAX_LINES 1000u static char *g_shadow = (void *)0; /* g_vt.rows * g_line_stride bytes */ static char *g_ring = (void *)0; /* g_ring_cap * g_line_stride bytes */ static uint32_t g_line_stride = 0; /* cols + 1 (NUL terminator) */ static uint32_t g_ring_count = 0; /* valid stored lines, <= g_ring_cap */ static uint32_t g_ring_head = 0; /* ring slot index of the oldest stored line */ static uint32_t g_ring_cap = 0; /* active ring capacity -- VT100_BOOT_SCROLLBACK_MAX_LINES * pre-TTF, VT100_SCROLLBACK_MAX_LINES from vt100_enable_ttf() * onward; see 4.4ac below */ static uint32_t g_scroll_offset = 0; /* 0 = live view */ /* ----------------------------------------------------------------------- * FABRIC.md item 4.4ac: boot-mode scrollback. font_8x16/bitmap-mode boot * output (POST, capsule birth, self-tests, heartbeat) previously had no * scrollback at all -- g_shadow/g_ring above were only ever allocated in * vt100_enable_ttf(), so anything printed before the REPL took over was * gone the moment it scrolled off-screen. This block gives boot mode its * own ring/shadow pair, allocated in vt100_init() at the bitmap cell * geometry (8x16), separate from the TTF-geometry pair above because the * two modes have different cell sizes and therefore different * g_line_stride -- reflowing one buffer's stored lines into the other's * width was rejected as unnecessary complexity for a text-only recall * (decided with Captain Bob 2026-08-12). * * g_boot_shadow/g_boot_ring are frozen (snapshotted, not appended to) * the moment vt100_enable_ttf() runs: it points g_shadow/g_ring at fresh * TTF-geometry buffers, so this file's four scrollback segments become, * oldest to newest: boot ring, boot shadow (the last screenful of boot * output, frozen as of the transition), TTF ring, TTF shadow (live). * scrollback_line_at()/scrollback_redraw() walk all four as one * continuous history, per Captain Bob's call that PgUp from the REPL * should reach all the way back through POST, not stop at a seam. * * Capacity guessed, not measured: 4096 lines vs. the TTF ring's 1000. * A real qemu-esp boot transcript observed 2026-08-12 ran to ~8,500 * lines of POST/self-test/heartbeat output -- 4096 covers roughly half * of that on its own; closing the rest of the gap is expected to come * from trimming that verbosity (a separate, still-open item), not from * growing this buffer indefinitely. Revisit if that trim doesn't happen. */ #define VT100_BOOT_SCROLLBACK_MAX_LINES 4096u static char *g_boot_shadow = (void *)0; /* frozen boot g_shadow, snapshotted at vt100_enable_ttf() */ static char *g_boot_ring = (void *)0; /* frozen boot g_ring */ static uint32_t g_boot_line_stride = 0; static uint32_t g_boot_ring_count = 0; static uint32_t g_boot_ring_head = 0; static uint32_t g_boot_ring_cap = 0; static uint32_t g_boot_rows = 0; /* g_vt.rows at snapshot time; 0 if boot alloc failed */ /* ----------------------------------------------------------------------- * Parser state enum * --------------------------------------------------------------------- */ typedef enum { ST_NORMAL = 0, ST_ESC, ST_CSI, ST_ESC_HASH, ST_ESC_CHAR, } ParseState; /* ----------------------------------------------------------------------- * Terminal state * --------------------------------------------------------------------- */ typedef struct { /* Geometry */ uint32_t cols; uint32_t rows; /* Cursor */ uint32_t cx; /* column (0-based) */ uint32_t cy; /* row (0-based) */ uint32_t saved_cx; uint32_t saved_cy; /* Colors & attributes */ uint32_t fg; /* packed 0x00RRGGBB */ uint32_t bg; uint32_t def_fg; uint32_t def_bg; int bold; int underline; int reverse; /* Escape parser */ ParseState state; int32_t params[VT100_MAX_PARAMS]; uint32_t param_count; int param_has_value; /* 1 if current param digit has been seen */ int initialized; } VT100State; static VT100State g_vt; /* ----------------------------------------------------------------------- * Forward declarations * --------------------------------------------------------------------- */ static void put_char(uint8_t ch); static void advance_cursor(void); static void scroll_up(uint32_t lines); static void erase_display(int mode); static void erase_line(int mode); static void set_cursor(uint32_t row, uint32_t col); static void apply_sgr(void); /* ----------------------------------------------------------------------- * Internal helpers * --------------------------------------------------------------------- */ /* Cell dimensions (pixels) — font_8x16.c's fixed 8x16 grid in bitmap mode, * or the provisional TTF cell (4.4j) once vt100_enable_ttf() has switched * modes. Mode-aware per 4.4i's finding that the grid was hardcoded to * font_8x16.c specifically; this is the generalization that item called * for. */ static uint32_t cell_w(void) { return g_glyph_mode == VT_GLYPH_TTF ? VT100_TTF_CELL_W_PX : fb_cell_w(); } static uint32_t cell_h(void) { return g_glyph_mode == VT_GLYPH_TTF ? VT100_TTF_CELL_H_PX : fb_cell_h(); } /* Pixel position of the top-left corner of character cell (cx, cy). */ static uint32_t px_of(uint32_t col) { return col * cell_w(); } static uint32_t py_of(uint32_t row) { return row * cell_h(); } /* Effective fg / bg accounting for reverse video */ static uint32_t eff_fg(void) { return g_vt.reverse ? g_vt.bg : g_vt.fg; } static uint32_t eff_bg(void) { return g_vt.reverse ? g_vt.fg : g_vt.bg; } /* Draw one glyph via the TTF rasterizer into the cell at pixel (cell_px, * cell_py). Mirrors fb_draw_glyph()'s opaque-cell contract (every pixel in * the cell gets either fg or bg, not just the glyph's "ink") by filling * the whole cell with bg first, then stamping covered bitmap pixels with * fg. Baseline sits VT100_TTF_SIZE_PX down from the cell's top, matching * TTF-TEXT's own bitmap-origin convention (ttf.h's * "glyph baseline sits at pixel (MARGIN, size_px + MARGIN)"). Bitmap * pixels are clipped to the cell rect -- a glyph's rasterized bounds can * slightly exceed the nominal advance width, and cells must stay opaque * neighbors, not bleed into each other. */ static void ttf_draw_glyph_cell(uint32_t cell_px, uint32_t cell_py, uint8_t ch, uint32_t fg, uint32_t bg) { ttf_bitmap_t bmp; int rc, was_hit; uint32_t bx, by; uint32_t baseline_y = cell_py + VT100_TTF_SIZE_PX; fb_fill_rect(cell_px, cell_py, cell_w(), cell_h(), bg); if (!g_ttf_ready) return; /* shouldn't happen once VT_GLYPH_TTF is set, defensive only */ rc = ttf_raster_cache_get(&g_ttf_cache, &g_ttf_font, (uint32_t)ch, VT100_TTF_SIZE_PX, &bmp, &was_hit); if (rc != TTF_OK) return; /* missing/unrasterizable glyph -- leave cell blank (bg only) */ for (by = 0; by < bmp.height; by++) { int32_t fb_y = (int32_t)baseline_y + (int32_t)by - (int32_t)VT100_TTF_SIZE_PX - TTF_CACHE_MARGIN; if (fb_y < (int32_t)cell_py || fb_y >= (int32_t)(cell_py + cell_h())) continue; for (bx = 0; bx < bmp.width; bx++) { int32_t fb_x = (int32_t)cell_px + (int32_t)bx - TTF_CACHE_MARGIN; if (fb_x < (int32_t)cell_px || fb_x >= (int32_t)(cell_px + cell_w())) continue; if (bmp.pixels[by * bmp.width + bx]) { fb_put_pixel((uint32_t)fb_x, (uint32_t)fb_y, fg); } } } } static void draw_cursor_glyph(uint8_t ch) { uint32_t f = eff_fg(); uint32_t b = eff_bg(); /* FABRIC.md item 4.4q: mirror every drawn character into the * scrollback shadow buffer, when active. */ if (g_shadow && g_vt.cy < g_vt.rows && g_vt.cx < g_vt.cols) { g_shadow[(size_t)g_vt.cy * g_line_stride + g_vt.cx] = (char)ch; } if (g_vt.bold) { /* Lighten fg for bold */ uint32_t r = FB_R(f); uint32_t g = FB_G(f); uint32_t bv= FB_B(f); r = r > 0xAAu ? 0xFFu : (r + 0x55u > 0xFFu ? 0xFFu : r + 0x55u); g = g > 0xAAu ? 0xFFu : (g + 0x55u > 0xFFu ? 0xFFu : g + 0x55u); bv= bv> 0xAAu ? 0xFFu : (bv+ 0x55u > 0xFFu ? 0xFFu : bv+ 0x55u); f = FB_RGB(r, g, bv); } if (!g_terminal_visible) return; /* 4.4y-revised: graphics mode -- logical state * only (already updated above), no framebuffer write */ if (g_glyph_mode == VT_GLYPH_TTF) { ttf_draw_glyph_cell(px_of(g_vt.cx), py_of(g_vt.cy), ch, f, b); } else { fb_draw_glyph(px_of(g_vt.cx), py_of(g_vt.cy), ch, f, b); } if (g_vt.underline) { /* font_8x16 mode: underline sits at UNDERLINE_ROW (row 14 of 16) * of that font's fixed grid, scaled. TTF mode: no equivalent fixed * row exists (this parser doesn't read hhea underline metrics -- * see ttf.h) -- placed 2px below the baseline instead, a * provisional choice like the rest of 4.4j's TTF cell geometry. */ uint32_t scale = g_glyph_mode == VT_GLYPH_TTF ? 1u : cell_h() / 16u; uint32_t ul_y = g_glyph_mode == VT_GLYPH_TTF ? py_of(g_vt.cy) + VT100_TTF_SIZE_PX + 2u : py_of(g_vt.cy) + UNDERLINE_ROW * scale; fb_fill_rect(px_of(g_vt.cx), ul_y, cell_w(), scale, f); } } /* Solid block cursor at the current position (g_vt.cx, g_vt.cy). Not * blinking -- a static indicator, simplest thing that actually shows * where typed input lands, callable as often as the caller likes since * it is idempotent (always draws the same thing at the current position). * A character typed at this position naturally overwrites it -- * draw_cursor_glyph() always fills the whole cell with bg before drawing * a glyph -- but *leaving* this position without drawing a character * (Enter/newline) does not, which is what vt100_erase_cursor() below is * for. No-op outside TTF mode or graphics mode (4.4ab's * g_terminal_visible), matching every other draw call in this file. */ void vt100_draw_cursor(void) { if (!g_terminal_visible || g_glyph_mode != VT_GLYPH_TTF) return; fb_fill_rect(px_of(g_vt.cx), py_of(g_vt.cy), cell_w(), cell_h(), eff_fg()); } /* Erases whatever vt100_draw_cursor() last drew at the current position, * restoring it to plain background -- call this before moving away from * a cursor-drawn cell without also drawing a character there (the * newline case in sk_readline: Enter moves to a new line, and nothing * else would ever overwrite the stray block left behind). */ void vt100_erase_cursor(void) { if (!g_terminal_visible || g_glyph_mode != VT_GLYPH_TTF) return; fb_fill_rect(px_of(g_vt.cx), py_of(g_vt.cy), cell_w(), cell_h(), eff_bg()); } static void reset_attrs(void) { g_vt.fg = g_vt.def_fg; g_vt.bg = g_vt.def_bg; g_vt.bold = 0; g_vt.underline = 0; g_vt.reverse = 0; } static void reset_parser(void) { uint32_t i; g_vt.state = ST_NORMAL; g_vt.param_count = 0; g_vt.param_has_value = 0; for (i = 0; i < VT100_MAX_PARAMS; i++) { g_vt.params[i] = -1; /* -1 = not set (use default) */ } } /* Return parameter N, substituting `def` when not set */ static int32_t param(uint32_t n, int32_t def) { if (n >= g_vt.param_count) return def; return (g_vt.params[n] < 0) ? def : g_vt.params[n]; } /* ----------------------------------------------------------------------- * Public API * --------------------------------------------------------------------- */ void vt100_init(void) { if (!fb_is_available()) return; g_vt.cols = fb_width() / cell_w(); g_vt.rows = fb_height() / cell_h(); g_vt.def_fg = VT100_DEFAULT_FG; g_vt.def_bg = VT100_DEFAULT_BG; g_vt.cx = g_vt.cy = 0; g_vt.saved_cx = g_vt.saved_cy = 0; reset_attrs(); reset_parser(); g_vt.initialized = 1; erase_display(2); /* FABRIC.md item 4.4ac: allocate boot-mode scrollback now, at the * bitmap cell geometry just computed above. Non-fatal on failure, same * pattern as vt100_enable_ttf()'s TTF allocation below: g_shadow/g_ring * stay NULL, draw_cursor_glyph()/scroll_up() already gate on that and * skip scrollback tracking, so boot text just isn't recoverable -- * exactly the pre-4.4ac behavior, not a new failure mode. */ { uint32_t i; g_line_stride = g_vt.cols + 1u; g_ring_cap = VT100_BOOT_SCROLLBACK_MAX_LINES; g_shadow = (char *)kmalloc((size_t)g_vt.rows * g_line_stride); g_ring = (char *)kmalloc((size_t)g_ring_cap * g_line_stride); if (!g_shadow || !g_ring) { log_message(LOG_ERROR, "vt100: boot scrollback buffer allocation failed, unavailable"); if (g_shadow) { kfree(g_shadow); g_shadow = (void *)0; } if (g_ring) { kfree(g_ring); g_ring = (void *)0; } } else { for (i = 0; i < g_vt.rows; i++) { uint32_t c; char *row = g_shadow + (size_t)i * g_line_stride; for (c = 0; c < g_vt.cols; c++) row[c] = ' '; row[g_vt.cols] = '\0'; } } g_ring_count = 0; g_ring_head = 0; } } void vt100_enable_ttf(void) { if (g_glyph_mode == VT_GLYPH_TTF) return; /* already switched */ if (!g_vt.initialized) return; if (!g_ttf_ready) { ttf_raster_cache_slot_t *slots; int rc; rc = ttf_load_from_capsule(capsule_get_directory(), capsule_get_descriptors(), capsule_get_names(), capsule_get_arena(), VT100_TTF_FONT_CAPSULE, &g_ttf_font); if (rc != TTF_OK) { log_message(LOG_ERROR, "vt100: TTF font capsule load failed (rc=%d), staying on font_8x16", rc); return; } slots = (ttf_raster_cache_slot_t *)kmalloc(sizeof(ttf_raster_cache_slot_t) * VT100_TTF_CACHE_SLOTS); if (!slots) { log_message(LOG_ERROR, "vt100: TTF cache allocation failed, staying on font_8x16"); return; } ttf_raster_cache_init(&g_ttf_cache, slots, VT100_TTF_CACHE_SLOTS); g_ttf_ready = 1; } /* FABRIC.md item 4.4ac: freeze the boot-mode scrollback buffers (if * any -- vt100_init() may have failed to allocate them) before they * get overwritten below. g_vt.cols/rows/g_shadow/g_ring/g_line_stride/ * g_ring_count/g_ring_head/g_ring_cap still hold boot-mode values at * this point, nothing below has touched them yet. g_boot_rows is only * set non-zero when the snapshot is actually usable -- if boot alloc * failed, g_shadow is already NULL and g_ring_count is already 0 ( * scroll_up() never touches the ring when g_shadow/g_ring are NULL), * so leaving g_boot_rows at 0 keeps scrollback_line_at()'s combined * index space consistent instead of pointing into a NULL buffer. */ g_boot_shadow = g_shadow; g_boot_ring = g_ring; g_boot_line_stride = g_line_stride; g_boot_ring_count = g_ring_count; g_boot_ring_head = g_ring_head; g_boot_ring_cap = g_ring_cap; g_boot_rows = g_shadow ? g_vt.rows : 0; g_glyph_mode = VT_GLYPH_TTF; /* Full-screen grid, TTF cell size -- see this file's header comment. * cols/rows MUST be set before anything below derives stride from * them via g_line_stride. */ g_vt.cols = fb_width() / cell_w(); g_vt.rows = fb_height() / cell_h(); g_vt.cx = g_vt.cy = 0; /* One-time full-screen clear at the bitmap-to-TTF transition: the boot/ * POST scroll left the whole framebuffer covered in font_8x16 text. * erase_display(2) below would cover this on its own now that it's * always full-screen, but the explicit fill stays as the "clean slate * before anything else runs" step it always was. */ fb_fill_rect(0, 0, fb_width(), fb_height(), g_vt.def_bg); erase_display(2); /* FABRIC.md item 4.4q: allocate the scrollback shadow + ring buffers * now that cols/rows are fixed for the rest of this session. Failure * is non-fatal -- scrollback just stays unavailable (g_shadow/g_ring * stay NULL, every scrollback call site already checks), REPL text * output itself is unaffected either way. */ { uint32_t i; g_line_stride = g_vt.cols + 1u; g_ring_cap = VT100_SCROLLBACK_MAX_LINES; g_shadow = (char *)kmalloc((size_t)g_vt.rows * g_line_stride); g_ring = (char *)kmalloc((size_t)g_ring_cap * g_line_stride); if (!g_shadow || !g_ring) { log_message(LOG_ERROR, "vt100: scrollback buffer allocation failed, scrollback unavailable"); if (g_shadow) { kfree(g_shadow); g_shadow = (void *)0; } if (g_ring) { kfree(g_ring); g_ring = (void *)0; } } else { for (i = 0; i < g_vt.rows; i++) { uint32_t c; char *row = g_shadow + (size_t)i * g_line_stride; for (c = 0; c < g_vt.cols; c++) row[c] = ' '; row[g_vt.cols] = '\0'; } } g_ring_count = 0; g_ring_head = 0; g_scroll_offset = 0; } } /* Fetch the text of combined-history index `idx` (0 = oldest boot-ring * entry, ... newest TTF-shadow row = live) -- four segments back to back: * boot ring, boot shadow (frozen at the boot->TTF transition), TTF ring, * TTF shadow (live). See 4.4ac's block above for why boot and TTF each * keep their own ring/shadow pair (different cell geometry, different * g_line_stride) instead of one reflowed buffer, and 4.4q's block for the * ring-then-shadow model each pair follows individually. Only ever called * once TTF mode is active (scrollback is wired to a keyboard command that * doesn't exist before the REPL starts -- console.c's * console_fb_scroll_back()/_fwd()), so g_ring/g_shadow here always mean * the TTF pair, never the boot one. */ static const char *scrollback_line_at(uint32_t idx) { if (idx < g_boot_ring_count) { uint32_t slot = (g_boot_ring_head + idx) % g_boot_ring_cap; return g_boot_ring + (size_t)slot * g_boot_line_stride; } idx -= g_boot_ring_count; if (idx < g_boot_rows) { return g_boot_shadow + (size_t)idx * g_boot_line_stride; } idx -= g_boot_rows; if (idx < g_ring_count) { uint32_t slot = (g_ring_head + idx) % g_ring_cap; return g_ring + (size_t)slot * g_line_stride; } else { uint32_t row = idx - g_ring_count; if (row >= g_vt.rows) row = g_vt.rows - 1; /* defensive clamp */ return g_shadow + (size_t)row * g_line_stride; } } /* Redraw every row from the combined history at the current * g_scroll_offset. Uses the terminal's current default colors, not each * line's original SGR state -- see this file's 4.4q block. `total` now * spans all four 4.4ac segments, not just the TTF ring + shadow, so * scrolling back from the REPL reaches all the way through POST. */ static void scrollback_redraw(void) { uint32_t total = g_boot_ring_count + g_boot_rows + g_ring_count + g_vt.rows; uint32_t row; for (row = 0; row < g_vt.rows; row++) { uint32_t idx = total - g_vt.rows - g_scroll_offset + row; const char *text = scrollback_line_at(idx); uint32_t col; for (col = 0; col < g_vt.cols; col++) { uint8_t ch = (uint8_t)(text[col] ? text[col] : ' '); if (g_glyph_mode == VT_GLYPH_TTF) { ttf_draw_glyph_cell(px_of(col), py_of(row), ch, g_vt.def_fg, g_vt.def_bg); } else { fb_draw_glyph(px_of(col), py_of(row), ch, g_vt.def_fg, g_vt.def_bg); } } } } /* FABRIC.md item 4.4y-revised: Alt+TAB graphics/text toggle, a two-state * machine (VISIBLE <-> HIDDEN) with exactly one transition function -- * both the physical Alt+TAB interception (repl.c) and the ALT+TAB FORTH * word (keyboard_words.c) call this same function, so there is exactly * one place the state actually flips, not two call sites each doing it * independently. * * VISIBLE -> HIDDEN: flip the flag only. draw_cursor_glyph()/ * erase_line_range()/erase_display()/scroll_up() all check * g_terminal_visible and skip their framebuffer writes from this point * on, while still advancing g_vt's cursor/attributes and the g_shadow/ * g_ring scrollback state normally -- graphics-mode drawing (direct * framebuffer or TTF-TEXT calls) is then free to use the whole screen, * and the terminal's logical state stays perfectly consistent with what * it would have been had it stayed visible. * * HIDDEN -> VISIBLE: flip the flag, then wipe whatever graphics drew and * reuse scrollback_redraw() to repaint the terminal's current on-screen * content from that untouched logical state -- restored exactly as it * logically stood, not merely as it last looked on screen. */ void vt100_toggle_graphics(void) { if (!g_ttf_ready || g_glyph_mode != VT_GLYPH_TTF) return; if (g_terminal_visible) { g_terminal_visible = 0; return; } g_terminal_visible = 1; fb_fill_rect(0, 0, fb_width(), fb_height(), g_vt.def_bg); scrollback_redraw(); } void vt100_scroll_back(uint32_t n) { uint32_t history; /* 4.4ac: boot ring + boot shadow + TTF ring, not just the TTF ring */ if (!g_shadow || !g_ring) return; history = g_boot_ring_count + g_boot_rows + g_ring_count; if (n > history - g_scroll_offset) n = history - g_scroll_offset; if (n == 0) return; g_scroll_offset += n; scrollback_redraw(); } void vt100_scroll_fwd(uint32_t n) { if (!g_shadow || !g_ring) return; if (n > g_scroll_offset) n = g_scroll_offset; if (n == 0) return; g_scroll_offset -= n; scrollback_redraw(); } uint32_t vt100_cols(void) { return g_vt.cols; } uint32_t vt100_rows(void) { return g_vt.rows; } /* ----------------------------------------------------------------------- * Cursor movement * --------------------------------------------------------------------- */ static void set_cursor(uint32_t row, uint32_t col) { if (row >= g_vt.rows) row = g_vt.rows - 1; if (col >= g_vt.cols) col = g_vt.cols - 1; g_vt.cy = row; g_vt.cx = col; } static void advance_cursor(void) { g_vt.cx++; if (g_vt.cx >= g_vt.cols) { g_vt.cx = 0; g_vt.cy++; if (g_vt.cy >= g_vt.rows) { scroll_up(1); g_vt.cy = g_vt.rows - 1; } } } /* FABRIC.md item 4.4q: push the rows about to be discarded into the * scrollback ring before the pixel scroll happens, then shift the shadow * buffer to match. `n` is clamped to g_vt.rows -- scrolling further than * a full screen at once cannot happen through this code path (advance_cursor * only ever calls scroll_up(1)), but the clamp keeps this correct even if * a future caller passes more. */ static void scroll_up(uint32_t lines) { if (g_shadow && g_ring && lines > 0) { uint32_t n = lines > g_vt.rows ? g_vt.rows : lines; uint32_t i, r; for (r = 0; r < n; r++) { uint32_t slot = (g_ring_head + g_ring_count) % g_ring_cap; char *dst = g_ring + (size_t)slot * g_line_stride; char *src = g_shadow + (size_t)r * g_line_stride; for (i = 0; i < g_line_stride; i++) dst[i] = src[i]; if (g_ring_count < g_ring_cap) { g_ring_count++; } else { g_ring_head = (g_ring_head + 1) % g_ring_cap; } } for (r = 0; r + n < g_vt.rows; r++) { char *dst = g_shadow + (size_t)r * g_line_stride; char *src = g_shadow + (size_t)(r + n) * g_line_stride; for (i = 0; i < g_line_stride; i++) dst[i] = src[i]; } for (r = g_vt.rows - n; r < g_vt.rows; r++) { char *row = g_shadow + (size_t)r * g_line_stride; for (i = 0; i < g_vt.cols; i++) row[i] = ' '; row[g_vt.cols] = '\0'; } } /* Full-framebuffer scroll -- both glyph modes cover the whole screen * now, so there is no box to scope this to. */ if (g_terminal_visible) { fb_scroll_rows(lines, g_vt.def_bg); } } /* ----------------------------------------------------------------------- * Erase operations * --------------------------------------------------------------------- */ /* FABRIC.md item 4.4i found this drawing a per-cell blank glyph * (font_8x16.c-specific, and inconsistent with erase_display(2)'s * full-screen case just below, which already used a plain rect fill). * 4.4j fixes both problems at once: a blank cell is visually identical to * a filled bg rect regardless of glyph backend, so this needs no font * involvement at all -- one fb_fill_rect() for the whole range instead of * a per-column glyph draw. */ static void erase_line_range(uint32_t row, uint32_t c0, uint32_t c1) { if (c1 <= c0) return; if (!g_terminal_visible) return; /* 4.4y-revised: graphics mode */ fb_fill_rect(px_of(c0), py_of(row), (c1 - c0) * cell_w(), cell_h(), g_vt.bg); } static void erase_display(int mode) { if (mode == 0) { /* erase from cursor to end */ erase_line_range(g_vt.cy, g_vt.cx, g_vt.cols); uint32_t r; for (r = g_vt.cy + 1; r < g_vt.rows; r++) { erase_line_range(r, 0, g_vt.cols); } } else if (mode == 1) { /* erase from start to cursor */ uint32_t r; for (r = 0; r < g_vt.cy; r++) { erase_line_range(r, 0, g_vt.cols); } erase_line_range(g_vt.cy, 0, g_vt.cx + 1); } else { /* erase entire screen -- full framebuffer, both glyph modes. */ if (g_terminal_visible) { fb_fill_rect(0, 0, fb_width(), fb_height(), g_vt.def_bg); } g_vt.cx = g_vt.cy = 0; } } static void erase_line(int mode) { if (mode == 0) { erase_line_range(g_vt.cy, g_vt.cx, g_vt.cols); } else if (mode == 1) { erase_line_range(g_vt.cy, 0, g_vt.cx + 1); } else { erase_line_range(g_vt.cy, 0, g_vt.cols); } } /* ----------------------------------------------------------------------- * SGR (Select Graphic Rendition) handler * * The parameter list may contain sub-sequences like: * 38 ; 2 ; r ; g ; b truecolor fg * 38 ; 5 ; n 256-color fg * 48 ; 2 ; r ; g ; b truecolor bg * 48 ; 5 ; n 256-color bg * These are walked in a sub-loop. * --------------------------------------------------------------------- */ /* 6×6×6 colour cube + grayscale ramp for 256-color mode */ static uint32_t ansi256_to_rgb(uint32_t idx) { if (idx < 16u) { return FB_ANSI_PALETTE[idx]; } if (idx >= 232u) { uint32_t v = 8u + (idx - 232u) * 10u; return FB_RGB(v, v, v); } /* 6×6×6 colour cube: indices 16–231 */ idx -= 16u; uint32_t r6 = idx / 36u; uint32_t g6 = (idx % 36u) / 6u; uint32_t b6 = idx % 6u; uint32_t r = r6 ? (55u + r6 * 40u) : 0u; uint32_t g = g6 ? (55u + g6 * 40u) : 0u; uint32_t b = b6 ? (55u + b6 * 40u) : 0u; return FB_RGB(r, g, b); } static void apply_sgr(void) { uint32_t i = 0; if (g_vt.param_count == 0) { reset_attrs(); return; } while (i < g_vt.param_count) { int32_t p = param(i, 0); if (p == 0) { reset_attrs(); } else if (p == 1) { g_vt.bold = 1; } else if (p == 4) { g_vt.underline = 1; } else if (p == 7) { g_vt.reverse = 1; } else if (p == 21 || p == 22) { g_vt.bold = 0; } else if (p == 24) { g_vt.underline = 0; } else if (p == 27) { g_vt.reverse = 0; } else if (p >= 30 && p <= 37) { g_vt.fg = FB_ANSI_PALETTE[p - 30]; } else if (p == 38) { int32_t mode = param(i + 1, -1); if (mode == 2 && i + 4 < g_vt.param_count) { uint32_t r = (uint32_t)param(i + 2, 0); uint32_t g = (uint32_t)param(i + 3, 0); uint32_t b = (uint32_t)param(i + 4, 0); g_vt.fg = FB_RGB(r, g, b); i += 4; } else if (mode == 5 && i + 2 < g_vt.param_count) { g_vt.fg = ansi256_to_rgb((uint32_t)param(i + 2, 0)); i += 2; } } else if (p == 39) { g_vt.fg = g_vt.def_fg; } else if (p >= 40 && p <= 47) { g_vt.bg = FB_ANSI_PALETTE[p - 40]; } else if (p == 48) { int32_t mode = param(i + 1, -1); if (mode == 2 && i + 4 < g_vt.param_count) { uint32_t r = (uint32_t)param(i + 2, 0); uint32_t g = (uint32_t)param(i + 3, 0); uint32_t b = (uint32_t)param(i + 4, 0); g_vt.bg = FB_RGB(r, g, b); i += 4; } else if (mode == 5 && i + 2 < g_vt.param_count) { g_vt.bg = ansi256_to_rgb((uint32_t)param(i + 2, 0)); i += 2; } } else if (p == 49) { g_vt.bg = g_vt.def_bg; } else if (p >= 90 && p <= 97) { g_vt.fg = FB_ANSI_PALETTE[p - 90 + 8]; } else if (p >= 100 && p <= 107) { g_vt.bg = FB_ANSI_PALETTE[p - 100 + 8]; } i++; } } /* ----------------------------------------------------------------------- * CSI dispatch * --------------------------------------------------------------------- */ static void dispatch_csi(char final) { int32_t p0 = param(0, -1); int32_t p1 = param(1, -1); int32_t n; switch (final) { case 'A': /* CUU cursor up */ n = (p0 < 1) ? 1 : p0; g_vt.cy = (g_vt.cy >= (uint32_t)n) ? g_vt.cy - (uint32_t)n : 0; break; case 'B': /* CUD cursor down */ n = (p0 < 1) ? 1 : p0; g_vt.cy += (uint32_t)n; if (g_vt.cy >= g_vt.rows) g_vt.cy = g_vt.rows - 1; break; case 'C': /* CUF cursor forward */ n = (p0 < 1) ? 1 : p0; g_vt.cx += (uint32_t)n; if (g_vt.cx >= g_vt.cols) g_vt.cx = g_vt.cols - 1; break; case 'D': /* CUB cursor back */ n = (p0 < 1) ? 1 : p0; g_vt.cx = (g_vt.cx >= (uint32_t)n) ? g_vt.cx - (uint32_t)n : 0; break; case 'H': /* CUP cursor position ESC[row;colH (1-based) */ case 'f': { uint32_t row = (p0 <= 0) ? 0 : (uint32_t)(p0 - 1); uint32_t col = (p1 <= 0) ? 0 : (uint32_t)(p1 - 1); set_cursor(row, col); } break; case 'J': /* ED erase in display */ erase_display((p0 < 0) ? 0 : p0); break; case 'K': /* EL erase in line */ erase_line((p0 < 0) ? 0 : p0); break; case 'm': /* SGR */ apply_sgr(); break; case 's': /* SCP save cursor */ g_vt.saved_cx = g_vt.cx; g_vt.saved_cy = g_vt.cy; break; case 'u': /* RCP restore cursor */ set_cursor(g_vt.saved_cy, g_vt.saved_cx); break; /* unrecognised sequences are silently ignored */ default: break; } } /* ----------------------------------------------------------------------- * Printable character output * --------------------------------------------------------------------- */ static void put_char(uint8_t ch) { draw_cursor_glyph(ch); advance_cursor(); } /* ----------------------------------------------------------------------- * vt100_putc — the main entry point * --------------------------------------------------------------------- */ void vt100_putc(char raw) { uint8_t c = (uint8_t)raw; if (!g_vt.initialized) return; switch (g_vt.state) { /* ------------------------------------------------------------------ */ case ST_NORMAL: if (c == 0x1B) { g_vt.state = ST_ESC; } else if (c == '\r') { g_vt.cx = 0; } else if (c == '\n') { g_vt.cy++; if (g_vt.cy >= g_vt.rows) { scroll_up(1); g_vt.cy = g_vt.rows - 1; } } else if (c == '\t') { /* advance to next 8-column tab stop */ g_vt.cx = (g_vt.cx + 8u) & ~7u; if (g_vt.cx >= g_vt.cols) { g_vt.cx = 0; g_vt.cy++; if (g_vt.cy >= g_vt.rows) { scroll_up(1); g_vt.cy = g_vt.rows - 1; } } } else if (c == '\b') { if (g_vt.cx > 0) g_vt.cx--; } else if (c == 0x07) { /* BEL — ignored */ } else if (c >= 0x20u) { put_char(c); } break; /* ------------------------------------------------------------------ */ case ST_ESC: if (c == '[') { reset_parser(); g_vt.state = ST_CSI; } else if (c == '7') { g_vt.saved_cx = g_vt.cx; g_vt.saved_cy = g_vt.cy; g_vt.state = ST_NORMAL; } else if (c == '8') { set_cursor(g_vt.saved_cy, g_vt.saved_cx); g_vt.state = ST_NORMAL; } else if (c == 'M') { /* RI reverse index: scroll down (insert line at top) */ if (g_vt.cy == 0) { /* Scroll display down: not trivially done without a * full back-buffer; fill top row with bg instead. */ fb_fill_rect(0, 0, fb_width(), cell_h(), g_vt.def_bg); } else { g_vt.cy--; } g_vt.state = ST_NORMAL; } else if (c == 'c') { /* RIS full reset */ reset_attrs(); g_vt.cx = g_vt.cy = 0; erase_display(2); g_vt.state = ST_NORMAL; } else if (c == '#') { g_vt.state = ST_ESC_HASH; } else if (c == '(' || c == ')') { g_vt.state = ST_ESC_CHAR; } else { /* Unknown 2-char ESC sequence — absorb and reset */ g_vt.state = ST_NORMAL; } break; /* ------------------------------------------------------------------ */ case ST_ESC_HASH: case ST_ESC_CHAR: /* Eat the designator byte and return to normal */ g_vt.state = ST_NORMAL; break; /* ------------------------------------------------------------------ */ case ST_CSI: if (c >= '0' && c <= '9') { uint32_t i = g_vt.param_count; if (i < VT100_MAX_PARAMS) { if (g_vt.params[i] < 0) g_vt.params[i] = 0; g_vt.params[i] = g_vt.params[i] * 10 + (int32_t)(c - '0'); g_vt.param_has_value = 1; } } else if (c == ';') { if (g_vt.param_count < VT100_MAX_PARAMS - 1u) { g_vt.param_count++; } g_vt.param_has_value = 0; } else if (c == '?' || c == '>' || c == '<' || c == '!') { /* Private / intermediate bytes — swallow, keep parsing */ } else if (c >= 0x40 && c <= 0x7E) { /* Final byte — dispatch and return to normal */ if (g_vt.param_has_value || g_vt.param_count > 0) { g_vt.param_count++; } dispatch_csi((char)c); g_vt.state = ST_NORMAL; } else { /* Unexpected — abort */ g_vt.state = ST_NORMAL; } break; } } void vt100_puts(const char *s) { if (!s) return; while (*s) { vt100_putc(*s++); } }