灵符lingfu

ling-lang

↓ 0 downloads

Ling - The Omniglot Systems Language

Keywords systems, polyglot, ai, crypto, game
lingfu install ling-lang
LicenseApache-2.0 OR MIT
AuthorsLing Lin, Sanny Lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (995)

crates/ling-kernel/src/drivers/terminal.rs
//! A desktop terminal window: a colored, themed, zoomable shell. The grid
//! resolution is derived from the window size divided by the zoomed glyph
//! size, so Ctrl +/- literally changes how many characters fit -- "text
//! resolution by text-size zoom", as asked.
//!
//! The shell here is kernel-side and independent of `lsh` (which is a
//! separate `.ling` program compiled into the text kernel, not this
//! graphics one). It supports a useful built-in command set wired
//! straight to kernel services -- lingfs (`ls`/`cat`), the network
//! (`curl`, `dns`), the clock (`date`), theming (`theme`), clipboard
//! (`copy`/`paste`) -- rather than pretending to run arbitrary programs
//! (there's no process model here; disclosed). Output lines carry a color
//! class so errors read red, ok green, and command echoes accent.

use crate::arch::rtc;
use crate::drivers::{clipboard, font8x8, framebuffer, lingfu, llm, media, mixer, netstack, theme};
use crate::fs::{lingfs, users};

const COLS: usize = 128; // max stored chars per line
const LINES: usize = 240; // scrollback ring
const INPUT_MAX: usize = 200;

#[derive(Clone, Copy, PartialEq)]
enum Cls {
    Normal,
    Accent,
    Ok,
    Err,
    Dim,
}

#[derive(Clone, Copy)]
struct Row {
    text: [u8; COLS],
    len: usize,
    cls: Cls,
}
const EMPTY_ROW: Row = Row { text: [0; COLS], len: 0, cls: Cls::Normal };

static mut ROWS: [Row; LINES] = [EMPTY_ROW; LINES];
static mut HEAD: usize = 0; // next write index (ring)
static mut COUNT: usize = 0; // rows written (<= LINES)
static mut INPUT: [u8; INPUT_MAX] = [0; INPUT_MAX];
static mut INPUT_LEN: usize = 0;
static mut ZOOM: u32 = 1;
static mut SCROLL: usize = 0; // rows scrolled up from the bottom
static mut STARTED: bool = false;

// Command history (up/down recall), like a real shell.
const HIST_N: usize = 32;
static mut HIST: [[u8; INPUT_MAX]; HIST_N] = [[0; INPUT_MAX]; HIST_N];
static mut HIST_LEN: [usize; HIST_N] = [0; HIST_N];
static mut HIST_HEAD: usize = 0; // next write slot (ring)
static mut HIST_COUNT: usize = 0; // entries stored (<= HIST_N)
static mut HIST_NAV: usize = 0; // steps back currently recalled (0 = fresh line)

// Current working directory: a single lingfs dir name (empty = root). lingfs
// is one level deep, so this is at most one component.
static mut CWD: [u8; 64] = [0; 64];
static mut CWD_LEN: usize = 0;

// sudo/su password entry: 0 = normal input, 1 = collecting a sudo password,
// 2 = collecting an su password. PENDING holds the sudo command line (mode 1)
// or the target username (mode 2).
static mut PW_MODE: u8 = 0;
static mut PW_INPUT: [u8; INPUT_MAX] = [0; INPUT_MAX];
static mut PW_LEN: usize = 0;
static mut PENDING: [u8; INPUT_MAX] = [0; INPUT_MAX];
static mut PENDING_LEN: usize = 0;

fn rows() -> &'static mut [Row; LINES] {
    unsafe { &mut *&raw mut ROWS }
}

fn push(cls: Cls, s: &[u8]) {
    unsafe {
        let r = &mut rows()[HEAD];
        let n = s.len().min(COLS);
        r.text[..n].copy_from_slice(&s[..n]);
        r.len = n;
        r.cls = cls;
        HEAD = (HEAD + 1) % LINES;
        if COUNT < LINES {
            COUNT += 1;
        }
        SCROLL = 0; // jump to bottom on new output
    }
}

/// Append possibly-multi-line bytes, splitting on '\n' and wrapping long
/// lines at COLS -- the shape terminal output actually arrives in.
fn push_wrapped(cls: Cls, bytes: &[u8]) {
    let mut line_start = 0;
    for i in 0..=bytes.len() {
        let at_nl = i < bytes.len() && bytes[i] == b'\n';
        let at_end = i == bytes.len();
        if at_nl || at_end || i - line_start >= COLS {
            push(cls, &bytes[line_start..i]);
            line_start = if at_nl { i + 1 } else { i };
            if at_end {
                break;
            }
        }
    }
}

fn banner() {
    push(Cls::Accent, b"LingOS terminal -- type 'help'");
    push(Cls::Dim, b"a graphics-mode shell (separate from the text-mode lsh)");
}

fn prompt_echo() {
    unsafe {
        let mut line = [0u8; INPUT_MAX + 8];
        line[0] = b'>';
        line[1] = b' ';
        let n = INPUT_LEN.min(INPUT_MAX);
        line[2..2 + n].copy_from_slice(&(&*&raw const INPUT)[..n]);
        push(Cls::Accent, &line[..2 + n]);
    }
}

fn input_str() -> &'static str {
    unsafe { core::str::from_utf8(&(&*&raw const INPUT)[..INPUT_LEN]).unwrap_or("") }
}

/// Run `line` in this terminal exactly as if the user typed and submitted it:
/// echo the prompt, record it in history, dispatch. The WM uses this to launch
/// an installed package (`ling run pkg-<name>/main.ling`) into a Terminal
/// window from the start menu, reusing all the terminal's rendering + the
/// in-kernel Ling interpreter rather than duplicating an app runner.
pub fn run_command(line: &str) {
    unsafe {
        if !STARTED {
            STARTED = true;
            banner();
        }
        let mut echo = [0u8; INPUT_MAX + 8];
        echo[0] = b'>';
        echo[1] = b' ';
        let n = line.len().min(INPUT_MAX);
        echo[2..2 + n].copy_from_slice(&line.as_bytes()[..n]);
        push(Cls::Accent, &echo[..2 + n]);
        history_push(&line.as_bytes()[..n]);
        run(&line[..n]);
    }
}

fn cwd_str() -> &'static str {
    unsafe { core::str::from_utf8(&(&*&raw const CWD)[..CWD_LEN]).unwrap_or("") }
}

/// True if `name` is a directory at lingfs root (for `cd`).
fn dir_exists(name: &str) -> bool {
    let mut idx = 0;
    let mut nm = [0u8; 64];
    while idx < 256 {
        match lingfs::list_entry("", idx, &mut nm) {
            Some((len, is_dir)) => {
                if is_dir && &nm[..len] == name.as_bytes() {
                    return true;
                }
                idx += 1;
            },
            None => break,
        }
    }
    false
}

/// Resolve a possibly-relative path against the CWD into `out`, returning the
/// slice. An absolute-looking path (with a '/') or an empty CWD passes
/// through unchanged; otherwise "cwd/name".
fn resolve_path<'a>(arg: &str, out: &'a mut [u8; 128]) -> &'a str {
    let cwd = cwd_str();
    if cwd.is_empty() || arg.contains('/') {
        let n = arg.len().min(out.len());
        out[..n].copy_from_slice(&arg.as_bytes()[..n]);
        return core::str::from_utf8(&out[..n]).unwrap_or("");
    }
    let mut n = 0;
    let cb = cwd.as_bytes();
    let cl = cb.len().min(out.len());
    out[..cl].copy_from_slice(&cb[..cl]);
    n += cl;
    if n < out.len() {
        out[n] = b'/';
        n += 1;
    }
    let ab = arg.as_bytes();
    let al = ab.len().min(out.len() - n);
    out[n..n + al].copy_from_slice(&ab[..al]);
    n += al;
    core::str::from_utf8(&out[..n]).unwrap_or("")
}

/// Record a command in history (skips empties and consecutive duplicates).
fn history_push(cmd: &[u8]) {
    unsafe {
        HIST_NAV = 0;
        if cmd.is_empty() {
            return;
        }
        if HIST_COUNT > 0 {
            let last = (HIST_HEAD + HIST_N - 1) % HIST_N;
            if HIST_LEN[last] == cmd.len() && HIST[last][..cmd.len()] == *cmd {
                return;
            }
        }
        let n = cmd.len().min(INPUT_MAX);
        HIST[HIST_HEAD][..n].copy_from_slice(&cmd[..n]);
        HIST_LEN[HIST_HEAD] = n;
        HIST_HEAD = (HIST_HEAD + 1) % HIST_N;
        if HIST_COUNT < HIST_N {
            HIST_COUNT += 1;
        }
    }
}

/// Recall a history entry into the input line. `delta` +1 = further back,
/// -1 = toward the fresh line.
fn history_recall(delta: i32) {
    unsafe {
        if HIST_COUNT == 0 {
            return;
        }
        let new_nav = (HIST_NAV as i32 + delta).clamp(0, HIST_COUNT as i32) as usize;
        HIST_NAV = new_nav;
        if new_nav == 0 {
            INPUT_LEN = 0;
            return;
        }
        let idx = (HIST_HEAD + HIST_N - new_nav) % HIST_N;
        let len = HIST_LEN[idx];
        INPUT[..len].copy_from_slice(&HIST[idx][..len]);
        INPUT_LEN = len;
    }
}

fn split_cmd(s: &str) -> (&str, &str) {
    match s.find(' ') {
        Some(i) => (&s[..i], s[i + 1..].trim_start()),
        None => (s, ""),
    }
}

/// show whatever the model task has produced since the last look
fn pump_llm(cols: usize) {
    if !llm::has_output() {
        return;
    }
    llm::drain_lines(cols.clamp(20, COLS - 1), &mut |kind, s| {
        let cls = match kind {
            0 => Cls::Normal,
            1 => Cls::Dim,
            _ => Cls::Err,
        };
        push(cls, s.as_bytes());
    });
}

fn run(line: &str) {
    pump_llm(COLS - 1);
    let (cmd, arg) = split_cmd(line.trim());
    match cmd {
        "" => {},
        "ls" if crate::fs::media::is_media_path(arg) => {
            let items = crate::vfs::list(arg);
            if items.is_empty() {
                push(Cls::Dim, b"(empty)");
            }
            for (name, is_dir, size) in items {
                let mut line = alloc::string::String::from(name.as_str());
                if is_dir {
                    line.push('/');
                } else {
                    line.push_str(&alloc::format!("  {size}"));
                }
                push(Cls::Normal, line.as_bytes());
            }
        },
        "cat" if crate::fs::media::is_media_path(arg) => match crate::vfs::read(arg) {
            Some(bytes) => push_wrapped(Cls::Normal, &bytes[..bytes.len().min(8192)]),
            None => push(Cls::Err, b"cat: no such file"),
        },
        "mkdir" if crate::fs::media::is_media_path(arg) => {
            if crate::vfs::mkdir(arg) {
                push(Cls::Ok, b"created");
            } else {
                push(Cls::Err, b"mkdir: failed");
            }
        },
        "help" => {
            push(Cls::Normal, b"commands:");
            push(Cls::Normal, b"  help  clear  echo <t>  ls [dir]  cat <file>");
            push(Cls::Normal, b"  date  theme dark|light  copy <t>  paste");
            push(Cls::Normal, b"  dns <host>   curl <url>   bring <url>");
            push(Cls::Normal, b"  play <file.wav>   stop");
            push(Cls::Normal, b"  lingfu sync|search <q>|install <name>");
            push(Cls::Normal, b"  ling run <file.ling>|eval <src>   run demo|donut|<app.elf>");
            push(Cls::Normal, b"  cd <dir>   sudo <cmd>   su [user]   ling-life");
            push(Cls::Normal, b"  ssh [user@]host   services [enable|disable ssh]   cryptotest");
            push(Cls::Normal, b"  https <url>   imgview demo|demosvg|<url>");
            push(Cls::Normal, b"  ask <question>   llm [status|load|stop]   (Qwen3, on this machine)");
            push(Cls::Dim, b"  up/down: command history");
        },
        "clear" => unsafe {
            COUNT = 0;
            HEAD = 0;
            SCROLL = 0;
        },
        "echo" => push(Cls::Normal, arg.as_bytes()),
        "ask" => {
            if arg.is_empty() {
                push(Cls::Err, b"usage: ask <question>");
            } else if !llm::start() {
                push(Cls::Err, b"ask: no free task slot for the model");
            } else if !llm::ask(arg) {
                push(Cls::Dim, b"llm is still busy with the last question (llm stop)");
            } else if llm::phase() == llm::Phase::Idle || llm::phase() == llm::Phase::Failed {
                push(Cls::Dim, b"asking: the first question loads the model from the model disk...");
            } else {
                push(Cls::Dim, b"asking...");
            }
        },
        "llm" => match arg {
            "" | "status" => {
                let s = llm::status();
                push(Cls::Normal, s.as_bytes());
            },
            "load" => {
                if llm::start() {
                    llm::request_load();
                    push(Cls::Dim, b"loading the model from the model disk...");
                } else {
                    push(Cls::Err, b"llm: no free task slot");
                }
            },
            "stop" => {
                llm::cancel();
                push(Cls::Dim, b"stopping");
            },
            _ => push(Cls::Err, b"usage: llm [status|load|stop]"),
        },
        "play" => {
            if arg.is_empty() {
                push(Cls::Err, b"usage: play <file.wav>");
            } else if media::open(arg) {
                mixer::pcm_play();
                push(Cls::Ok, b"playing (media)");
            } else {
                push(Cls::Err, media::status().as_bytes());
            }
        },
        "stop" => {
            mixer::pcm_stop();
            push(Cls::Dim, b"stopped");
        },
        "date" => {
            let dt = rtc::read();
            let mut b = [0u8; 32];
            let mut n = 0;
            n += w4(&mut b[n..], dt.year as u32);
            b[n] = b'-'; n += 1;
            n += w2(&mut b[n..], dt.month as u32);
            b[n] = b'-'; n += 1;
            n += w2(&mut b[n..], dt.day as u32);
            b[n] = b' '; n += 1;
            n += w2(&mut b[n..], dt.hour as u32);
            b[n] = b':'; n += 1;
            n += w2(&mut b[n..], dt.minute as u32);
            b[n] = b':'; n += 1;
            n += w2(&mut b[n..], dt.second as u32);
            push(Cls::Normal, &b[..n]);
            push(Cls::Dim, b"(CMOS RTC, UTC)");
        },
        "theme" => {
            if arg == "light" {
                theme::set(1);
            } else if arg == "dark" {
                theme::set(0);
            } else {
                push(Cls::Err, b"usage: theme dark|light");
            }
        },
        "copy" => {
            clipboard::set(arg.as_bytes());
            push(Cls::Ok, b"copied to clipboard");
        },
        "paste" => push(Cls::Normal, clipboard::get()),
        "ls" => {
            let dir = if arg.is_empty() { cwd_str() } else { arg };
            let mut idx = 0;
            let mut nm = [0u8; 64];
            let mut any = false;
            while idx < 128 {
                match lingfs::list_entry(dir, idx, &mut nm) {
                    Some((len, is_dir)) => {
                        let mut line = [0u8; 70];
                        let mut n = 0;
                        line[..len].copy_from_slice(&nm[..len]);
                        n += len;
                        if is_dir {
                            line[n] = b'/';
                            n += 1;
                        }
                        push(if is_dir { Cls::Accent } else { Cls::Normal }, &line[..n]);
                        any = true;
                        idx += 1;
                    },
                    None => break,
                }
            }
            if !any {
                push(Cls::Dim, b"(empty)");
            }
        },
        "cat" => {
            if arg.is_empty() {
                push(Cls::Err, b"usage: cat <file>");
            } else {
                static mut CATBUF: [u8; 8192] = [0; 8192];
                let cb = unsafe { &mut *&raw mut CATBUF };
                let mut pbuf = [0u8; 128];
                let path = resolve_path(arg, &mut pbuf);
                match lingfs::read_file_all(path, cb) {
                    Ok(Some(len)) => push_wrapped(Cls::Normal, &cb[..len]),
                    _ => push(Cls::Err, b"cat: no such file"),
                }
            }
        },
        "cp" | "mv" => {
            let mut it = arg.split_whitespace();
            let (Some(a), Some(b)) = (it.next(), it.next()) else {
                push(Cls::Err, if cmd == "cp" { b"usage: cp <src> <dst>" } else { b"usage: mv <src> <dst>" });
                return;
            };
            let (mut pa, mut pb) = ([0u8; 128], [0u8; 128]);
            let src = alloc::string::String::from(resolve_path(a, &mut pa));
            let dst = crate::vfs::target_for(&src, resolve_path(b, &mut pb));
            let ok = if cmd == "cp" { crate::vfs::copy(&src, &dst) } else { crate::vfs::rename(&src, &dst) };
            if ok {
                push(Cls::Ok, dst.as_bytes());
            } else {
                push(Cls::Err, b"failed (source missing, destination not writable, or folder not empty)");
            }
        },
        "rm" => {
            if arg.is_empty() {
                push(Cls::Err, b"usage: rm <path>");
            } else {
                let mut pb = [0u8; 128];
                let p = resolve_path(arg, &mut pb);
                if crate::vfs::delete(p) {
                    push(Cls::Ok, b"deleted");
                } else {
                    push(Cls::Err, b"rm: not deleted (missing, a non-empty folder, or read-only storage)");
                }
            }
        },
        "dns" => {
            if arg.is_empty() {
                push(Cls::Err, b"usage: dns <host>");
            } else {
                push(Cls::Dim, b"resolving...");
                match netstack::dns_resolve(arg) {
                    Some(ip) => {
                        let mut b = [0u8; 32];
                        let mut n = 0;
                        for (k, oct) in ip.iter().enumerate() {
                            if k > 0 {
                                b[n] = b'.';
                                n += 1;
                            }
                            n += w_any(&mut b[n..], *oct as u32);
                        }
                        push(Cls::Ok, &b[..n]);
                    },
                    None => push(Cls::Err, b"dns: could not resolve"),
                }
            }
        },
        "curl" => {
            if arg.is_empty() {
                push(Cls::Err, b"usage: curl <url>");
            } else {
                fetch_and_dump(arg, false);
            }
        },
        "bring" => {
            if arg.is_empty() {
                push(Cls::Err, b"usage: bring <url>");
            } else {
                fetch_and_dump(arg, true);
            }
        },
        "lingfu" => run_lingfu(arg),
        "ling" => run_ling(arg),
        "run" => run_native(arg),
        "ssh" => run_ssh(arg),
        "services" => run_services(arg),
        "https" => run_https(arg),
        "imgview" => run_imgview(arg),
        "cd" => {
            if arg.is_empty() || arg == "/" || arg == "~" || arg == ".." {
                unsafe { CWD_LEN = 0 };
            } else if dir_exists(arg) {
                let b = arg.as_bytes();
                let n = b.len().min(64);
                unsafe {
                    CWD[..n].copy_from_slice(&b[..n]);
                    CWD_LEN = n;
                }
            } else {
                push(Cls::Err, b"cd: no such directory");
            }
        },
        "pwd" => {
            let c = cwd_str();
            if c.is_empty() {
                push(Cls::Normal, b"/");
            } else {
                let mut b = [0u8; 66];
                b[0] = b'/';
                let n = c.len().min(64);
                b[1..1 + n].copy_from_slice(&c.as_bytes()[..n]);
                push(Cls::Normal, &b[..1 + n]);
            }
        },
        "whoami" => push(Cls::Normal, lingfs::current_user().as_bytes()),
        "sudo" => {
            if arg.is_empty() {
                push(Cls::Err, b"usage: sudo <command>");
            } else {
                let b = arg.as_bytes();
                let n = b.len().min(INPUT_MAX);
                unsafe {
                    PENDING[..n].copy_from_slice(&b[..n]);
                    PENDING_LEN = n;
                    PW_MODE = 1;
                    PW_LEN = 0;
                }
                let mut m = [0u8; 64];
                let p = b"[sudo] password for ";
                m[..p.len()].copy_from_slice(p);
                let u = lingfs::current_user();
                let un = u.len().min(m.len() - p.len() - 1);
                m[p.len()..p.len() + un].copy_from_slice(&u.as_bytes()[..un]);
                m[p.len() + un] = b':';
                push(Cls::Dim, &m[..p.len() + un + 1]);
            }
        },
        "su" => {
            let target = if arg.is_empty() { "root" } else { arg };
            let b = target.as_bytes();
            let n = b.len().min(INPUT_MAX);
            unsafe {
                PENDING[..n].copy_from_slice(&b[..n]);
                PENDING_LEN = n;
                PW_MODE = 2;
                PW_LEN = 0;
            }
            push(Cls::Dim, b"password:");
        },
        "ling-life" => {
            // life::run() is the fullscreen text-mode idle screensaver; it
            // takes over the console and can't render inside this framebuffer
            // window, so don't launch it from here (it would freeze the WM).
            push(Cls::Normal, b"ling-life: Conway's Life screensaver.");
            push(Cls::Dim, b"It runs fullscreen on idle; a windowed Life app is on the roadmap.");
        },
        "cryptotest" => {
            // Prove the TLS 1.3 crypto suite computes correct answers in-kernel
            // (known-answer test vectors), not just that it compiled.
            let t = crate::crypto::selftest();
            let mark = |ok: bool| if ok { b"[ok]  ".as_slice() } else { b"[FAIL]".as_slice() };
            let mut row = |ok: bool, name: &[u8]| {
                let mut b = [0u8; 48];
                let m = mark(ok);
                b[..m.len()].copy_from_slice(m);
                b[m.len()] = b' ';
                let n = name.len().min(b.len() - m.len() - 1);
                b[m.len() + 1..m.len() + 1 + n].copy_from_slice(&name[..n]);
                push(if ok { Cls::Ok } else { Cls::Err }, &b[..m.len() + 1 + n]);
            };
            push(Cls::Accent, b"TLS crypto self-test (known-answer vectors):");
            row(t.rdrand, b"RDRAND entropy");
            row(t.sha256, b"SHA-256          (FIPS 180-4)");
            row(t.sha512, b"SHA-512          (FIPS 180-4)");
            row(t.hmac, b"HMAC-SHA256      (RFC 4231)");
            row(t.hkdf, b"HKDF-SHA256      (RFC 5869)");
            row(t.chachapoly, b"ChaCha20-Poly1305 (RFC 8439)");
            row(t.x25519, b"X25519 ECDHE     (RFC 7748)");
            row(t.ed25519, b"Ed25519 sign+vfy (RFC 8032)");
            if t.all_ok() {
                push(Cls::Ok, b"all primitives pass -- ready for TLS + SSH");
            } else {
                push(Cls::Err, b"some primitives failed");
            }
        },
        "alloctest" => {
            // Exercise the new kernel global allocator: a growing Vec (which
            // reallocs), a String, then drop (which frees). Prints a known
            // checksum -- sum of i*i for i in 0..1000 == 332833500 -- so a
            // correct allocator is verifiable at a glance.
            let mut v: alloc::vec::Vec<u32> = alloc::vec::Vec::new();
            let mut i: u32 = 0;
            while i < 1000 {
                v.push(i * i);
                i += 1;
            }
            let mut sum: u32 = 0;
            for x in v.iter() {
                sum = sum.wrapping_add(*x);
            }
            let mut s = alloc::string::String::new();
            s.push_str("alloc ok: sum=");
            let mut nb = [0u8; 16];
            let n = w_any(&mut nb, sum);
            if let Ok(ds) = core::str::from_utf8(&nb[..n]) {
                s.push_str(ds);
            }
            s.push_str(" len=");
            let mut lb = [0u8; 16];
            let ln = w_any(&mut lb, v.len() as u32);
            if let Ok(ls) = core::str::from_utf8(&lb[..ln]) {
                s.push_str(ls);
            }
            push(Cls::Ok, s.as_bytes());
            // v and s drop here, exercising free().
        },
        _ => {
            let mut b = [0u8; 96];
            let p = b"unknown command: ";
            b[..p.len()].copy_from_slice(p);
            let n = (cmd.len()).min(b.len() - p.len());
            b[p.len()..p.len() + n].copy_from_slice(&cmd.as_bytes()[..n]);
            push(Cls::Err, &b[..p.len() + n]);
        },
    }
}

/// The desktop-terminal front end for the kernel `lingfu` package client.
/// Handles the *package-management* verbs that work without a compiler
/// (sync/search/install/update); the project/toolchain verbs (new, build,
/// run, test, ...) need the `ling` compiler, which doesn't run inside LingOS
/// yet -- so we say so plainly rather than pretend. lingfu writes through a
/// capture sink here so its output lands in this window, not the text console.
fn run_lingfu(arg: &str) {
    let (sub, rest) = split_cmd(arg);
    match sub {
        "" | "help" => {
            push(Cls::Accent, b"lingfu -- LingOS package client");
            push(Cls::Normal, b"  lingfu sync              refresh the catalog from the repo");
            push(Cls::Normal, b"  lingfu search <query>    find packages");
            push(Cls::Normal, b"  lingfu install <name>    download + install a package");
            push(Cls::Normal, b"  lingfu update            re-sync the catalog");
            push(Cls::Dim, b"  new/build/run/test need the ling compiler (host-only for now)");
        },
        "new" | "init" | "build" | "run" | "test" | "check" | "clean" | "doc" | "fmt"
        | "bench" | "tree" | "publish" | "add" | "manifest" | "wizard" => {
            push(Cls::Err, b"lingfu: that verb needs the ling compiler/toolchain,");
            push(Cls::Err, b"which doesn't run inside LingOS yet (no userspace/loader).");
            push(Cls::Dim, b"Package verbs that DO work here: sync, search, install, update.");
        },
        "sync" | "update" => {
            push(Cls::Dim, b"contacting repo...");
            lingfu::begin_capture();
            lingfu::sync();
            dump_capture();
        },
        "search" | "list" => {
            lingfu::begin_capture();
            if !lingfu::synced() {
                lingfu::sync();
            }
            lingfu::list(rest);
            dump_capture();
        },
        "install" => {
            if rest.is_empty() {
                push(Cls::Err, b"usage: lingfu install <name>");
            } else {
                push(Cls::Dim, b"installing...");
                lingfu::begin_capture();
                lingfu::install(rest);
                dump_capture();
            }
        },
        other => {
            let mut b = [0u8; 64];
            let p = b"lingfu: unknown subcommand: ";
            b[..p.len()].copy_from_slice(p);
            let n = other.len().min(b.len() - p.len());
            b[p.len()..p.len() + n].copy_from_slice(&other.as_bytes()[..n]);
            push(Cls::Err, &b[..p.len() + n]);
        },
    }
}

/// `ling run <file.ling> [args]` / `ling eval <source>` -- run a Ling program
/// with the in-kernel compiler + interpreter (crate::lingc: the real lexer,
/// parser and MIR lowering, interpreted over the same runtime bundled apps
/// use). Output and any error are printed into this window.
fn run_ling(arg: &str) {
    let (sub, rest) = split_cmd(arg);
    match sub {
        "" | "help" | "--help" | "-h" => {
            push(Cls::Accent, b"ling -- run Ling programs (in-kernel compiler + interpreter)");
            push(Cls::Normal, b"  ling run <file.ling> [args]   run a program (a folder runs its main.ling)");
            push(Cls::Normal, b"  ling eval <source>            run a one-line program");
            push(Cls::Normal, b"  ling demo                     a built-in test program");
            push(Cls::Dim, b"  the whole language; builtins: core, strings, lists, st_*, files, px_* graphics, daw_*");
        },
        "eval" => {
            if rest.is_empty() {
                push(Cls::Err, b"usage: ling eval <source>");
            } else {
                run_ling_source(rest);
            }
        },
        "demo" => run_ling_source(crate::ling::DEMO),
        "run" => {
            let mut words = rest.split(' ').filter(|w| !w.is_empty());
            let Some(file) = words.next() else {
                push(Cls::Err, b"usage: ling run <file.ling> [args]");
                return;
            };
            let args: alloc::vec::Vec<&str> = words.collect();
            let mut pbuf = [0u8; 128];
            let path = resolve_path(file, &mut pbuf);
            // Relative to the current folder first, then as typed from the
            // root (`ling run pkg-<name>` works from anywhere).
            let (out, err) = crate::ling::run_file(&[path, file], &args);
            show_ling_result(out, err);
        },
        _ => push(Cls::Err, b"ling: unknown subcommand (try: ling run <file> | ling eval <src>)"),
    }
}

/// `run demo` / `run <path.elf>` -- load a native AOT-compiled Ling app into
/// a ring-3 process and run it to completion, showing its output here. This
/// is the real native-app path (Cranelift AOT -> ling-user -> ELF loader ->
/// ring 3 -> syscalls), distinct from `ling run` (the in-kernel interpreter).
fn run_native(arg: &str) {
    // The donut is a graphics app: it maps the framebuffer and draws straight
    // to the screen (no text output to capture), then returns when it exits.
    if arg == "donut" {
        push(Cls::Dim, b"spinning up the donut (press any key to exit)...");
        let result = crate::run_app_bytes(crate::DONUT_APP_ELF);
        report_exit(result);
        return;
    }
    push(Cls::Dim, b"launching native app (ring 3)...");
    crate::console_capture_begin();
    let result = if arg.is_empty() || arg == "demo" {
        crate::run_app_bytes(crate::HELLO_APP_ELF)
    } else {
        let mut pbuf = [0u8; 128];
        let path = resolve_path(arg, &mut pbuf);
        crate::run_app_file(path)
    };
    let cap = crate::console_capture_end();
    for line in cap.split(|&b| b == b'\n') {
        if !line.is_empty() {
            push_wrapped(Cls::Normal, line);
        }
    }
    report_exit(result);
}

fn report_exit(result: Result<i32, &'static str>) {
    match result {
        Ok(code) => {
            let mut b = [0u8; 32];
            let p = b"[exited, code ";
            b[..p.len()].copy_from_slice(p);
            let mut n = p.len();
            n += w_any(&mut b[n..], code as u32);
            b[n] = b']';
            n += 1;
            push(Cls::Ok, &b[..n]);
        },
        Err(e) => {
            let mut b = [0u8; 96];
            let p = b"run: ";
            b[..p.len()].copy_from_slice(p);
            let k = e.len().min(b.len() - p.len());
            b[p.len()..p.len() + k].copy_from_slice(&e.as_bytes()[..k]);
            push(Cls::Err, &b[..p.len() + k]);
        },
    }
}

/// `ssh [user@]host[:port]` -- connect over TCP and do the SSH version-banner
/// exchange (RFC 4253's first step), showing the server's banner. The full
/// SSH transport (KEX/auth/shell) is the large protocol work that builds on
/// crypto.rs; this proves the client reaches and speaks to a real sshd.
fn run_ssh(arg: &str) {
    if arg.is_empty() {
        push(Cls::Err, b"usage: ssh [user@]host[:port]");
        return;
    }
    let hostport = match arg.rfind('@') {
        Some(i) => &arg[i + 1..],
        None => arg,
    };
    let (host, port) = match hostport.rfind(':') {
        Some(i) => (&hostport[..i], hostport[i + 1..].parse::<u16>().unwrap_or(22)),
        None => (hostport, 22u16),
    };
    push(Cls::Dim, b"resolving...");
    let Some(ip) = netstack::dns_resolve(host) else {
        push(Cls::Err, b"ssh: could not resolve host");
        return;
    };
    push(Cls::Dim, b"connecting...");
    if !netstack::tcp_connect(ip, port) {
        push(Cls::Err, b"ssh: connection refused / no route");
        return;
    }
    netstack::tcp_write(b"SSH-2.0-LingOS_0.1\r\n");
    let mut buf = [0u8; 512];
    let n = netstack::tcp_read_some(&mut buf, 3_000_000);
    if n == 0 {
        push(Cls::Err, b"ssh: connected, but no banner received");
        return;
    }
    let mut end = 0;
    while end < n && buf[end] != b'\r' && buf[end] != b'\n' {
        end += 1;
    }
    push(Cls::Ok, &buf[..end]);
    push(Cls::Dim, b"ssh: TCP + SSH version banner exchanged with the server.");
    push(Cls::Dim, b"     KEX/auth/shell (the SSH transport) build on the crypto suite -- WIP.");
}

const PNG_SIG: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];

/// `imgview [demo|<url>]` -- fetch (http/https) and decode an image, then show
/// it fullscreen (press a key to return). PNG (8-bit) and subset SVG.
fn run_imgview(arg: &str) {
    static mut IMGBUF: [u8; 512 * 1024] = [0; 512 * 1024];
    let ib = unsafe { &mut *&raw mut IMGBUF };
    let (len, off): (usize, usize) = if arg.is_empty() || arg == "demo" {
        static TEST_PNG: &[u8] = include_bytes!("../../testbins/test.png");
        let n = TEST_PNG.len().min(ib.len());
        ib[..n].copy_from_slice(&TEST_PNG[..n]);
        (n, 0)
    } else if arg == "demosvg" {
        static TEST_SVG: &[u8] = include_bytes!("../../testbins/test.svg");
        let n = TEST_SVG.len().min(ib.len());
        ib[..n].copy_from_slice(&TEST_SVG[..n]);
        (n, 0)
    } else {
        let Some((host, port, path, tls)) = netstack::parse_url(arg) else {
            push(Cls::Err, b"imgview: bad URL");
            return;
        };
        push(Cls::Dim, b"imgview: fetching...");
        if tls {
            let hport = if port == 0 { 443 } else { port };
            let mut noop = |_: &[u8]| {};
            match crate::tls::https_get(host, hport, path, ib, &mut noop) {
                Ok(n) if n > 0 => (n, crate::tls::http_body_offset(&ib[..n])),
                _ => {
                    push(Cls::Err, b"imgview: https fetch failed");
                    return;
                },
            }
        } else {
            let Some(ip) = netstack::dns_resolve(host) else {
                push(Cls::Err, b"imgview: DNS failed");
                return;
            };
            match netstack::http_get(ip, port, path, host, ib) {
                Some(n) => (n, 0),
                None => {
                    push(Cls::Err, b"imgview: fetch failed");
                    return;
                },
            }
        }
    };
    let bytes = &ib[off..len];
    let img = if bytes.len() >= 8 && bytes[..8] == PNG_SIG {
        crate::drivers::image::decode_png(bytes)
    } else if crate::drivers::image::looks_svg(bytes) {
        crate::drivers::image::decode_svg(bytes)
    } else {
        None
    };
    match img {
        Some(im) => {
            push(Cls::Ok, b"imgview: decoded -- showing (press a key)");
            crate::drivers::image::show_fullscreen(&im);
            crate::arch::timer::poll_until(20_000_000, || crate::drivers::keyboard::poll_char() != 0);
        },
        None => push(Cls::Err, b"imgview: could not decode (PNG 8-bit or subset SVG)"),
    }
}

/// `https <host[:port][/path]>` -- a real TLS 1.3 handshake (X25519 +
/// ChaCha20-Poly1305) and an encrypted GET, showing the decrypted response.
/// Certificate validation is NOT performed yet (disclosed) -- this proves the
/// handshake + record crypto, not authenticity.
fn run_https(arg: &str) {
    if arg.is_empty() {
        push(Cls::Err, b"usage: https <host[:port][/path]>");
        return;
    }
    let a = arg.strip_prefix("https://").unwrap_or(arg);
    let (hostpart, path) = match a.find('/') {
        Some(i) => (&a[..i], &a[i..]),
        None => (a, "/"),
    };
    let (host, port) = match hostpart.rfind(':') {
        Some(i) => (&hostpart[..i], hostpart[i + 1..].parse::<u16>().unwrap_or(443)),
        None => (hostpart, 443u16),
    };
    push(Cls::Dim, b"tls: cert validation is NOT done yet -- channel is encrypted, not authenticated");
    static mut HTTPSBUF: [u8; 32 * 1024] = [0; 32 * 1024];
    let buf = unsafe { &mut *&raw mut HTTPSBUF };
    let mut logline = |m: &[u8]| {
        let m = if m.last() == Some(&b'\n') { &m[..m.len() - 1] } else { m };
        push(Cls::Dim, m);
    };
    match crate::tls::https_get(host, port, path, buf, &mut logline) {
        Ok(0) => push(Cls::Err, b"https: handshake ok but no data decrypted"),
        Ok(len) => {
            push(Cls::Ok, b"https: response (decrypted):");
            push_wrapped(Cls::Normal, &buf[..len.min(2048)]);
        },
        Err(e) => {
            let mut b = [0u8; 96];
            let p = b"https: ";
            b[..p.len()].copy_from_slice(p);
            let k = e.len().min(b.len() - p.len());
            b[p.len()..p.len() + k].copy_from_slice(&e.as_bytes()[..k]);
            push(Cls::Err, &b[..p.len() + k]);
        },
    }
}

/// `services [list|enable ssh|disable ssh]` -- configure which services start
/// at boot (persisted to lingfs /services, honored by services::boot_configure).
fn run_services(arg: &str) {
    let (sub, rest) = split_cmd(arg);
    match sub {
        "" | "list" | "status" => {
            if crate::services::ssh_enabled() {
                push(Cls::Ok, b"  ssh    enabled  (starts at boot)");
            } else {
                push(Cls::Dim, b"  ssh    disabled");
            }
            push(Cls::Dim, b"  services enable ssh | services disable ssh");
        },
        "enable" => {
            if rest == "ssh" {
                crate::services::set_ssh(true);
                push(Cls::Ok, b"ssh enabled -- starts at next boot");
            } else {
                push(Cls::Err, b"services: unknown service (only 'ssh')");
            }
        },
        "disable" => {
            if rest == "ssh" {
                crate::services::set_ssh(false);
                push(Cls::Ok, b"ssh disabled");
            } else {
                push(Cls::Err, b"services: unknown service (only 'ssh')");
            }
        },
        _ => push(Cls::Err, b"usage: services [list|enable ssh|disable ssh]"),
    }
}

fn run_ling_source(src: &str) {
    let (out, err) = crate::ling::run_source(src);
    show_ling_result(out, err);
}

fn show_ling_result(out: alloc::string::String, err: Option<alloc::string::String>) {
    if !out.is_empty() {
        push_wrapped(Cls::Normal, out.as_bytes());
    }
    if let Some(e) = err {
        let mut b = [0u8; 128];
        let p = b"ling error: ";
        b[..p.len()].copy_from_slice(p);
        let n = e.len().min(b.len() - p.len());
        b[p.len()..p.len() + n].copy_from_slice(&e.as_bytes()[..n]);
        push(Cls::Err, &b[..p.len() + n]);
    }
}

/// Push everything lingfu captured this call into the terminal, one line per
/// output line (blank lines dropped).
fn dump_capture() {
    let out = lingfu::end_capture();
    for line in out.split(|&b| b == b'\n') {
        if !line.is_empty() {
            push_wrapped(Cls::Normal, line);
        }
    }
}

fn fetch_and_dump(url: &str, render: bool) {
    let Some((host, port, path, tls)) = netstack::parse_url(url) else {
        push(Cls::Err, b"bad URL");
        return;
    };
    push(Cls::Dim, b"fetching...");
    static mut WEBBUF: [u8; 32 * 1024] = [0; 32 * 1024];
    let wb = unsafe { &mut *&raw mut WEBBUF };
    let (len, off) = if tls {
        // HTTPS via the in-kernel TLS 1.3 client (cert validation not done yet).
        let hport = if port == 0 { 443 } else { port };
        let mut noop = |_: &[u8]| {};
        match crate::tls::https_get(host, hport, path, wb, &mut noop) {
            Ok(n) if n > 0 => (n, crate::tls::http_body_offset(&wb[..n])),
            _ => {
                push(Cls::Err, b"https fetch failed (handshake/timeout)");
                return;
            },
        }
    } else {
        let Some(ip) = netstack::dns_resolve(host) else {
            push(Cls::Err, b"could not resolve host");
            return;
        };
        match netstack::http_get(ip, port, path, host, wb) {
            Some(n) => (n, 0usize),
            None => {
                push(Cls::Err, b"fetch failed (connect/non-200/timeout)");
                return;
            },
        }
    };
    if render {
        use bring_browser::{layout, Page};
        static mut PG: Page = Page::new();
        let pg = unsafe { &mut *&raw mut PG };
        layout(&wb[off..len], COLS - 2, pg);
        for l in pg.lines[..pg.line_count].iter() {
            let cls = if l.link != u8::MAX { Cls::Accent } else { Cls::Normal };
            push(cls, l.text());
        }
    } else {
        push_wrapped(Cls::Normal, &wb[off..len]);
    }
}

fn w2(buf: &mut [u8], v: u32) -> usize {
    buf[0] = b'0' + ((v / 10) % 10) as u8;
    buf[1] = b'0' + (v % 10) as u8;
    2
}
fn w4(buf: &mut [u8], v: u32) -> usize {
    buf[0] = b'0' + ((v / 1000) % 10) as u8;
    buf[1] = b'0' + ((v / 100) % 10) as u8;
    buf[2] = b'0' + ((v / 10) % 10) as u8;
    buf[3] = b'0' + (v % 10) as u8;
    4
}
fn w_any(buf: &mut [u8], mut v: u32) -> usize {
    if v == 0 {
        buf[0] = b'0';
        return 1;
    }
    let mut t = [0u8; 10];
    let mut k = 0;
    while v > 0 {
        t[k] = b'0' + (v % 10) as u8;
        v /= 10;
        k += 1;
    }
    for i in 0..k {
        buf[i] = t[k - 1 - i];
    }
    k
}

/// While collecting a sudo/su password: masked entry, Enter submits, Esc
/// cancels, Backspace edits. Returns true if the key was consumed (i.e. we're
/// in password mode). On success, sudo re-runs the pending command and su
/// switches the current user via `lingfs::set_current_user`.
fn handle_pw_key(k: u8) -> bool {
    unsafe {
        if PW_MODE == 0 {
            return false;
        }
        match k {
            b'\n' | b'\r' => {
                let mode = PW_MODE;
                let plen = PENDING_LEN;
                let mut pend = [0u8; INPUT_MAX];
                pend[..plen].copy_from_slice(&(&*&raw const PENDING)[..plen]);
                let pw = core::str::from_utf8(&(&*&raw const PW_INPUT)[..PW_LEN]).unwrap_or("");
                if mode == 1 {
                    let user = lingfs::current_user();
                    if users::verify(user, pw) {
                        push(Cls::Dim, b"[sudo] ok");
                        PW_MODE = 0;
                        PW_LEN = 0;
                        PENDING_LEN = 0;
                        run(core::str::from_utf8(&pend[..plen]).unwrap_or(""));
                        return true;
                    }
                    push(Cls::Err, b"sudo: authentication failure");
                } else if mode == 2 {
                    let target = core::str::from_utf8(&pend[..plen]).unwrap_or("root");
                    if users::verify(target, pw) {
                        let mut gb = [0u8; 32];
                        let glen = users::group_of(target, &mut gb).unwrap_or(0);
                        let grp = core::str::from_utf8(&gb[..glen]).unwrap_or("users");
                        lingfs::set_current_user(target, grp);
                        let mut m = [0u8; 48];
                        let p = b"now logged in as ";
                        m[..p.len()].copy_from_slice(p);
                        let tn = target.len().min(m.len() - p.len());
                        m[p.len()..p.len() + tn].copy_from_slice(&target.as_bytes()[..tn]);
                        push(Cls::Ok, &m[..p.len() + tn]);
                    } else {
                        push(Cls::Err, b"su: authentication failure");
                    }
                }
                PW_MODE = 0;
                PW_LEN = 0;
                PENDING_LEN = 0;
            },
            0x08 => {
                if PW_LEN > 0 {
                    PW_LEN -= 1;
                }
            },
            0x1B => {
                PW_MODE = 0;
                PW_LEN = 0;
                PENDING_LEN = 0;
                push(Cls::Dim, b"(cancelled)");
            },
            0x20..=0x7E => {
                if PW_LEN < INPUT_MAX {
                    PW_INPUT[PW_LEN] = k;
                    PW_LEN += 1;
                }
            },
            _ => {},
        }
        true
    }
}

pub fn key(k: u8) {
    use crate::drivers::keyboard as kb;
    unsafe {
        if !STARTED {
            STARTED = true;
            banner();
        }
        // While a sudo/su password is being collected, all keys feed the
        // masked entry, not the command line.
        if handle_pw_key(k) {
            return;
        }
        match k {
            kb::CTRL_ZOOM_IN => ZOOM = (ZOOM + 1).min(3),
            kb::CTRL_ZOOM_OUT => ZOOM = (ZOOM - 1).max(1),
            kb::CTRL_ZOOM_RESET => ZOOM = 1,
            kb::CTRL_C => {
                // Copy the last output line to the clipboard.
                if COUNT > 0 {
                    let last = (HEAD + LINES - 1) % LINES;
                    clipboard::set(&rows()[last].text[..rows()[last].len]);
                }
            },
            kb::CTRL_V => {
                let clip = clipboard::get();
                for &b in clip {
                    if b >= 0x20 && b < 0x7F && INPUT_LEN < INPUT_MAX {
                        INPUT[INPUT_LEN] = b;
                        INPUT_LEN += 1;
                    }
                }
            },
            // Up/Down walk command history (like a real shell), not the
            // scrollback -- new output already snaps the view to the bottom.
            kb::UP_ARROW => history_recall(1),
            kb::DOWN_ARROW => history_recall(-1),
            b'\n' | b'\r' => {
                prompt_echo();
                let mut tmp = [0u8; INPUT_MAX];
                let n = INPUT_LEN;
                tmp[..n].copy_from_slice(&(&*&raw const INPUT)[..n]);
                INPUT_LEN = 0;
                history_push(&tmp[..n]);
                run(core::str::from_utf8(&tmp[..n]).unwrap_or(""));
            },
            0x08 => {
                if INPUT_LEN > 0 {
                    INPUT_LEN -= 1;
                }
            },
            0x20..=0x7E => {
                if INPUT_LEN < INPUT_MAX {
                    INPUT[INPUT_LEN] = k;
                    INPUT_LEN += 1;
                }
            },
            _ => {},
        }
    }
}

fn cls_color(c: Cls) -> u32 {
    match c {
        Cls::Accent => theme::color(theme::SLOT_ACCENT),
        Cls::Ok => 0x6AC06A,
        Cls::Err => theme::color(theme::SLOT_ERROR),
        Cls::Dim => theme::color(theme::SLOT_DIM),
        Cls::Normal => theme::color(theme::SLOT_TEXT),
    }
}

/// Draw the terminal into a window content rect. Grid geometry follows the
/// zoom: bigger glyphs => fewer rows/cols => lower text resolution.
pub fn draw(x: u32, y: u32, w: u32, h: u32) {
    let panel = theme::color(theme::SLOT_PANEL);
    unsafe {
        if !STARTED {
            STARTED = true;
            banner();
        }
    }
    let z = unsafe { ZOOM };
    let gw = 8 * z;
    let gh = 8 * z + 2 * z;
    let cols = ((w / gw).max(1)) as usize;
    pump_llm(cols.saturating_sub(1));
    // Reserve the last grid row for the input prompt.
    let grid_rows = (h / gh).max(2) as usize;
    let out_rows = grid_rows - 1;

    let count = unsafe { COUNT };
    let head = unsafe { HEAD };
    let scroll = unsafe { SCROLL }.min(count.saturating_sub(1));
    // Bottom visible row is (count-1-scroll); show `out_rows` up from there.
    let last = count.saturating_sub(1).saturating_sub(scroll);
    let first = last.saturating_sub(out_rows - 1);
    let mut ry = y;
    for vis in first..=last {
        if count == 0 {
            break;
        }
        // ring index of logical row `vis` (0 = oldest kept)
        let ring = (head + LINES - count + vis) % LINES;
        let r = &rows()[ring];
        let color = cls_color(r.cls);
        let n = r.len.min(cols);
        for (ci, &ch) in r.text[..n].iter().enumerate() {
            font8x8::draw_char_scaled(x + ci as u32 * gw, ry, ch, color, panel, z);
        }
        ry += gh;
    }

    // Input prompt on the bottom row. In sudo/su password mode, show a
    // "password:" label and mask the entry with asterisks.
    let py = y + (grid_rows as u32 - 1) * gh;
    let accent = theme::color(theme::SLOT_ACCENT);
    let txt = theme::color(theme::SLOT_TEXT);
    let pw_mode = unsafe { PW_MODE };
    if pw_mode != 0 {
        let label: &[u8] = b"password:";
        for (ci, &ch) in label.iter().enumerate() {
            font8x8::draw_char_scaled(x + ci as u32 * gw, py, ch, theme::color(theme::SLOT_DIM), panel, z);
        }
        let pl = unsafe { PW_LEN };
        let base = label.len() as u32 + 1;
        let shown = pl.min(cols.saturating_sub(base as usize + 1));
        for i in 0..shown {
            font8x8::draw_char_scaled(x + (base + i as u32) * gw, py, b'*', txt, panel, z);
        }
        framebuffer::back_fill_rect(x + (base + shown as u32) * gw, py, gw / 4 + 1, gh, accent);
    } else {
        font8x8::draw_char_scaled(x, py, b'>', accent, panel, z);
        let inp = unsafe { &(&*&raw const INPUT)[..INPUT_LEN] };
        let shown = inp.len().min(cols.saturating_sub(3));
        let start = inp.len() - shown;
        for (ci, &ch) in inp[start..].iter().enumerate() {
            font8x8::draw_char_scaled(x + (ci as u32 + 2) * gw, py, ch, txt, panel, z);
        }
        framebuffer::back_fill_rect(x + (shown as u32 + 2) * gw, py, gw / 4 + 1, gh, accent);
    }
    let _ = input_str;
}

← Back to index