灵符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/wm.rs
//! Multi-window manager core -- the real window system behind the
//! `kernel/x86_64-wm` desktop. Generalizes `wm_liquid`'s single springy
//! rect into a registry of windows with z-order, focus, titlebar dragging,
//! close buttons, minimize-to-dock, a dock with launchers, and per-window
//! damped-oscillator "liquid" motion (same constants and feel as
//! `wm_liquid`, now per-window).
//!
//! Division of labour, same philosophy as before: *state and hit-testing*
//! live here (the `.ling` AOT path has no mutable rebinding -- see
//! `wm_liquid`'s module doc), while *what the chrome looks like* stays in
//! `.ling` (`kernel/x86_64-wm/main.ling` draws every shadow/frame/titlebar
//! itself from the slot getters below). The one exception is window
//! *content* (Settings rows, file listings): those need real loops over
//! runtime-sized data, which `.ling` kernel code cannot express (its loops
//! can't carry a counter), so [`draw_content`] renders window interiors
//! kernel-side.
//!
//! Not claimed: this is not a client/server display protocol. Windows are
//! kernel-side objects drawn by the kernel; there is no per-process
//! surface, no damage protocol, no IPC. The ring-3 process layer
//! (`proc/uproc.rs`) exists but `SYS_FB_MAP`/`SYS_POLL_INPUT` are still
//! ENOSYS -- until a process can map a surface and receive input events,
//! an X-style split would be theater. This is the honest single-address-
//! space ancestor of that design, shaped so windows could become client
//! surfaces later (each window already has its own identity, geometry,
//! and focus state).

use crate::arch::{rtc, timer};
use crate::drivers::{
    browser, display, editor, framebuffer, font8x8, font_unicode, gallery, horizon, kbdlayout,
    locale, media, messenger, mixer, netstack, pkgman, terminal, theme, ui_scale, wallpaper,
};
use crate::fs::lingfs;

// Same spring feel as wm_liquid.rs, applied per window. Stiffness is a
// runtime knob now (Settings > Window spring): higher snaps windows to
// the cursor faster with less soap-film wobble, lower is looser/jellier.
// Stored x1000 so the flat-u64 FFI / integer Settings row can carry it.
const DAMPING: f64 = 0.22;
const STRETCH_SENSITIVITY: f64 = 0.006;
const MAX_STRETCH: f64 = 0.35;
const SPRING_K_MIN: u32 = 10; // 0.010
const SPRING_K_MAX: u32 = 80; // 0.080
static mut SPRING_K_MILLI: u32 = 28; // 0.028 default

fn spring_k() -> f64 {
    unsafe { SPRING_K_MILLI as f64 / 1000.0 }
}

pub fn spring_stiffness_milli() -> u32 {
    unsafe { SPRING_K_MILLI }
}

pub fn spring_adjust(delta: i32) {
    unsafe {
        let v = (SPRING_K_MILLI as i32 + delta).clamp(SPRING_K_MIN as i32, SPRING_K_MAX as i32);
        SPRING_K_MILLI = v as u32;
    }
}

pub const MAX_WINDOWS: usize = 6;
pub const TITLEBAR_H: u32 = 28;

pub const KIND_ABOUT: u8 = 0;
pub const KIND_SETTINGS: u8 = 1;
pub const KIND_FILES: u8 = 2;
pub const KIND_WEB: u8 = 3;
pub const KIND_EDIT: u8 = 4;
pub const KIND_GALLERY: u8 = 5;
pub const KIND_TERM: u8 = 6;
pub const KIND_MEDIA: u8 = 7;
pub const KIND_PKG: u8 = 8;
pub const KIND_HORIZON: u8 = 9;
pub const KIND_MESSENGER: u8 = 10;
pub const KIND_WALLPICK: u8 = 11;
pub const KIND_DAW: u8 = 12;
const DOCK_APPS: [u8; 9] = [
    KIND_ABOUT,
    KIND_SETTINGS,
    KIND_FILES,
    KIND_WEB,
    KIND_EDIT,
    KIND_GALLERY,
    KIND_TERM,
    KIND_MEDIA,
    KIND_DAW,
];

#[derive(Clone, Copy)]
struct Window {
    used: bool,
    minimized: bool,
    kind: u8,
    w: u32,
    h: u32,
    // Spring state: rendered position chases target.
    px: f64,
    py: f64,
    vx: f64,
    vy: f64,
    tx: f64,
    ty: f64,
    scale_w: f64,
    scale_h: f64,
    // Soap-film bend: a horizontal lean that trails the drag velocity and
    // springs/wobbles as the window settles (see `step`). Rendered as a
    // per-scanline x-offset in `draw_window`.
    bend: f64,
    // Dissolve-on-close: a window stays in the table and z-order after
    // close() with `closing = true`, its `dissolve` ramping 0->1 over a
    // few frames; `draw_window` sublimates it (diagonal hash sweep + fade)
    // and `step` frees it at 1.0.
    closing: bool,
    dissolve: f64,
    // Maximize: when true the window fills the workspace (below the top bar,
    // above the dock). `sx/sy/sw/sh` remember the floating geometry so
    // un-maximizing restores it.
    maximized: bool,
    sx: f64,
    sy: f64,
    sw: u32,
    sh: u32,
}

const EMPTY_WINDOW: Window = Window {
    used: false,
    minimized: false,
    kind: 0,
    w: 0,
    h: 0,
    px: 0.0,
    py: 0.0,
    vx: 0.0,
    vy: 0.0,
    tx: 0.0,
    ty: 0.0,
    scale_w: 1.0,
    scale_h: 1.0,
    bend: 0.0,
    closing: false,
    dissolve: 0.0,
    maximized: false,
    sx: 0.0,
    sy: 0.0,
    sw: 0,
    sh: 0,
};

static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY_WINDOW; MAX_WINDOWS];
/// Z-order as indices into WINDOWS: `Z[0]` backmost, `Z[Z_LEN-1]` topmost
/// (focused). Minimized windows stay in the table but leave the z list.
static mut Z: [usize; MAX_WINDOWS] = [0; MAX_WINDOWS];
static mut Z_LEN: usize = 0;

static mut FRAME_NO: u32 = 0;
static mut DRAG_WIN: i32 = -1;
static mut GRAB_DX: f64 = 0.0;
static mut GRAB_DY: f64 = 0.0;
// Edge-resize drag: which window, which edge(s), and the geometry+cursor
// captured at press so the resize is computed as a delta from the grab point.
static mut RESIZE_WIN: i32 = -1;
static mut RESIZE_EDGE: u8 = 0;
static mut RES_MX0: f64 = 0.0;
static mut RES_MY0: f64 = 0.0;
static mut RES_PX0: f64 = 0.0;
static mut RES_PY0: f64 = 0.0;
static mut RES_W0: u32 = 0;
static mut RES_H0: u32 = 0;
static mut PREV_BUTTONS: u8 = 0;
static mut LAST_STEP_MS: u64 = 0;
static mut SPAWN_COUNT: u32 = 0;
static mut HOVER_DOCK: i32 = -1;

// -- Settings window state -------------------------------------------------
// Two tabs (Tab key switches):
//   General: UI theme / Sound theme / Wallpaper / Display / Window spring /
//            Keyboard / Language / Clock / DPI / Refresh
//   Network: IP mode / Nameserver / SSH at boot  (+ read-only IP/gw/DNS status)
// Most changes apply live as you arrow; Display applies on Enter/Apply/OK (a
// live resolution switch on DISPI adapters). Apply/OK persist everything to
// lingfs so it survives a reboot. General scrolls when it overflows (the cursor
// keeps itself visible; SETTINGS_SCROLL is the first visible row).
const SETTINGS_TAB_GENERAL: usize = 0;
const SETTINGS_TAB_NETWORK: usize = 1;
const SETTINGS_TAB_AUDIO: usize = 2;
const SETTINGS_TAB_COUNT: usize = 3;
const GENERAL_ROWS: usize = 11;
const NETWORK_ROWS: usize = 3;
const AUDIO_ROWS: usize = 3;
const SETTINGS_VISIBLE: usize = 8;
static mut SETTINGS_TAB: usize = 0;
static mut SETTINGS_CURSOR: usize = 0;
static mut SETTINGS_SCROLL: usize = 0;
// Network tab IP mode: 0 = DHCP, 1 = static (MAC-derived).
static mut NET_IP_MODE: usize = 0;

fn settings_tab_rows(tab: usize) -> usize {
    match tab {
        SETTINGS_TAB_NETWORK => NETWORK_ROWS,
        SETTINGS_TAB_AUDIO => AUDIO_ROWS,
        _ => GENERAL_ROWS,
    }
}
static mut CLOCK_24H: bool = true;

// Network: a DNS-server preset cycler (the Settings "DNS" row). Each preset
// is the primary resolver; the NAT resolver stays the guaranteed fallback
// (see netstack). Cutting-edge, privacy-first options first.
// Each preset is (label, ip, hostname). A non-empty hostname is a DNS server
// addressed by name (e.g. Ling's own dns.linglin.art) -- resolved via the
// current resolver at apply time, since you can't send DNS queries to a name.
const DNS_PRESETS: [(&str, [u8; 4], &str); 6] = [
    ("Cloudflare 1.1.1.1", [1, 1, 1, 1], ""),
    ("Quad9 9.9.9.9", [9, 9, 9, 9], ""),
    ("AdGuard 94.140.14.14", [94, 140, 14, 14], ""),
    ("Ling dns.linglin.art", [0, 0, 0, 0], "dns.linglin.art"),
    ("LAN 192.168.0.2", [192, 168, 0, 2], ""),
    ("NAT 10.0.2.3", [10, 0, 2, 3], ""),
];
static mut DNS_PRESET: usize = 0;

fn apply_dns_preset() {
    let (_, ip, host) = DNS_PRESETS[unsafe { DNS_PRESET } % DNS_PRESETS.len()];
    // A hostname preset resolves the server's name via whatever resolver is
    // live now; if that fails (offline, or it IS the resolver being changed),
    // fall back to the NAT resolver rather than setting 0.0.0.0.
    let primary = if !host.is_empty() {
        netstack::dns_resolve(host).unwrap_or(netstack::NAT_RESOLVER)
    } else {
        ip
    };
    netstack::set_dns(primary, netstack::NAT_RESOLVER);
}

/// Persist the live settings to lingfs `/settings` (one line of small
/// integers) so Apply/OK actually mean something across reboots. Format
/// is deliberately trivial and versioned by field order.
pub fn settings_save() {
    let mut line = [0u8; 64];
    let vals = [
        theme::current() as u32,
        mixer::sound_theme() as u32,
        unsafe { WALLPAPER },
        display::preferred() as u32,
        spring_stiffness_milli(),
        kbdlayout::current() as u32,
        locale::selected().unwrap_or(0) as u32,
        unsafe { CLOCK_24H as u32 },
        unsafe { DNS_PRESET as u32 },
        ui_scale::dpi_index() as u32,
        hz_index() as u32,
        wallpaper::scale_mode() as u32,
    ];
    let mut n = 0;
    for (i, v) in vals.iter().enumerate() {
        if i > 0 && n < line.len() {
            line[n] = b' ';
            n += 1;
        }
        n += write_u32_into(&mut line[n..], *v);
    }
    let _ = lingfs::write_file("settings", &line[..n]);
}

/// Restore settings written by `settings_save`. Called once at desktop
/// start. Silently keeps defaults if the file is absent/garbled.
pub fn settings_load() {
    let mut buf = [0u8; crate::fs::lingfs::BLOCK_SIZE];
    let Ok(Some(len)) = lingfs::read_file("settings", &mut buf) else { return };
    let mut it = buf[..len].split(|&b| b == b' ' || b == b'\n').filter(|s| !s.is_empty());
    let mut next = || -> Option<u32> {
        it.next().and_then(|s| core::str::from_utf8(s).ok()).and_then(|s| s.parse::<u32>().ok())
    };
    if let Some(v) = next() {
        theme::set(v as usize);
    }
    if let Some(v) = next() {
        mixer::set_sound_theme(v as usize);
    }
    if let Some(v) = next() {
        set_wallpaper(v);
    }
    let _display = next(); // display pref lives in the boot header, not here
    if let Some(v) = next() {
        unsafe { SPRING_K_MILLI = v.clamp(SPRING_K_MIN, SPRING_K_MAX) };
    }
    if let Some(v) = next() {
        kbdlayout::set_current(v as usize);
    }
    if let Some(v) = next() {
        locale::select(v as usize);
    }
    if let Some(v) = next() {
        unsafe { CLOCK_24H = v != 0 };
    }
    if let Some(v) = next() {
        unsafe { DNS_PRESET = (v as usize) % DNS_PRESETS.len() };
        apply_dns_preset();
    }
    if let Some(v) = next() {
        ui_scale::set_dpi_index(v as usize);
        font8x8::set_scale(ui_scale::dpi_font_scale());
    }
    if let Some(v) = next() {
        set_hz_index(v as usize);
    }
    if let Some(v) = next() {
        wallpaper::set_scale_mode(v as u8);
    }
}

fn write_u32_into(buf: &mut [u8], mut v: u32) -> usize {
    if buf.is_empty() {
        return 0;
    }
    if v == 0 {
        buf[0] = b'0';
        return 1;
    }
    let mut tmp = [0u8; 10];
    let mut k = 0;
    while v > 0 && k < tmp.len() {
        tmp[k] = b'0' + (v % 10) as u8;
        v /= 10;
        k += 1;
    }
    let n = k.min(buf.len());
    for i in 0..n {
        buf[i] = tmp[k - 1 - i];
    }
    n
}

// -- Wallpaper --------------------------------------------------------------
pub const WALL_GRADIENT: u32 = 0;
pub const WALL_ROYGBIV: u32 = 1;
pub const WALL_SOLID: u32 = 2;
pub const WALL_IMAGE: u32 = 3;
const WALL_MODES: u32 = 4;
static mut WALLPAPER: u32 = 0;
/// Set once the built-in wallpaper has been baked into the decode cache.
static mut WALLPAPER_INIT: bool = false;

pub fn wallpaper() -> u32 {
    unsafe { WALLPAPER }
}

pub fn set_wallpaper(mode: u32) {
    unsafe {
        // Image mode only sticks if one actually loaded; otherwise fall
        // back to the gradient rather than showing a blank cache.
        if mode % WALL_MODES == WALL_IMAGE && !wallpaper::loaded() {
            WALLPAPER = WALL_GRADIENT;
        } else {
            WALLPAPER = mode % WALL_MODES;
        }
    }
}

fn wallpaper_name(mode: u32) -> &'static str {
    match mode {
        WALL_ROYGBIV => "ROYGBIV",
        WALL_SOLID => "Solid",
        WALL_IMAGE => "Image",
        _ => "Gradient",
    }
}

/// Load a BMP from lingfs and switch to Image wallpaper (the Files app's
/// "set as wallpaper"). Returns false if it didn't decode.
pub fn set_wallpaper_image(name: &str) -> bool {
    if wallpaper::load(name) {
        unsafe { WALLPAPER = WALL_IMAGE };
        // Persist the choice so it survives a reboot (read in draw_wallpaper).
        let _ = lingfs::write_file("/wallpaper", name.as_bytes());
        true
    } else {
        false
    }
}

/// Promote the image already in the decode cache (the one the Gallery is
/// showing) to the desktop wallpaper -- no re-decode.
pub fn set_wallpaper_current() {
    if wallpaper::loaded() {
        unsafe { WALLPAPER = WALL_IMAGE };
    }
}

// -- Applications menu (MATE-style, top-left) -------------------------------
// A categorized launcher dropped from the top bar's brand corner. Headers
// aren't clickable; app rows open (or raise) their window and close the
// menu. Kept a flat always-expanded list -- simplest thing that reads as
// the MATE menu, and honest about how few apps exist to categorize yet.
// Power actions live in the menu as pseudo-"kinds" above the real window
// kinds so one click routing handles both.
const ACT_LOGOUT: u8 = 200;
const ACT_RESTART: u8 = 201;
const ACT_SHUTDOWN: u8 = 202;
const ACT_LAUNCH_PKG: u8 = 203;
/// Run the image's bundled compiled `.ling` app (`bundle_app` in Ling.toml —
/// LingStationX, LingOS Edition) full-screen; Ctrl+Q returns to the desktop.
const ACT_RUN_APP: u8 = 204;

// Installed lingfu packages that are runnable on LingOS (have a `main.ling`,
// at the top or under `src/` as the registry's packages do) appear in the
// start menu grouped by category. The list is rebuilt each time the menu opens
// (cheap; packages are few) by scanning lingfs `packages/`, checking each
// `pkg-<name>`, and reading its category from `pkg-<name>/Ling.toml` (default
// "Applications"). Rust-only source packages (no `main.ling`) are
// intentionally omitted -- they can't run here.
const MAX_MENU_PKGS: usize = 16;
// menu_walk encodes a clicked package row as this base + the package index, so
// it stays distinct from the real window kinds (0..=10) and ACT_* (200..).
const PKG_ACTION: u32 = 0x1000;
const MENU_HEADER: u32 = 0xFFFF;
static mut PKG_NAME: [[u8; 40]; MAX_MENU_PKGS] = [[0; 40]; MAX_MENU_PKGS];
static mut PKG_NAME_LEN: [usize; MAX_MENU_PKGS] = [0; MAX_MENU_PKGS];
static mut PKG_CAT: [[u8; 20]; MAX_MENU_PKGS] = [[0; 20]; MAX_MENU_PKGS];
static mut PKG_CAT_LEN: [usize; MAX_MENU_PKGS] = [0; MAX_MENU_PKGS];
static mut PKG_COUNT: usize = 0;
static mut PKG_LAUNCH: usize = 0;

static mut LOGOUT_REQUESTED: bool = false;

/// The desktop loop (main.ling) polls this each frame; when true it tears
/// down the session and returns to the greeter/login.
pub fn logout_requested() -> bool {
    unsafe { LOGOUT_REQUESTED }
}
pub fn clear_logout() {
    unsafe { LOGOUT_REQUESTED = false };
}

struct MenuRow {
    header: &'static str, // non-empty => category header (not an app row)
    app: &'static str,
    kind: u8,
}
const MENU: [MenuRow; 21] = [
    MenuRow { header: "Internet", app: "", kind: 255 },
    MenuRow { header: "", app: "bring (web browser)", kind: KIND_WEB },
    MenuRow { header: "", app: "horizon (box-model browser)", kind: KIND_HORIZON },
    MenuRow { header: "", app: "Messenger", kind: KIND_MESSENGER },
    MenuRow { header: "Accessories", app: "", kind: 255 },
    MenuRow { header: "", app: "Terminal", kind: KIND_TERM },
    MenuRow { header: "", app: "Editor", kind: KIND_EDIT },
    MenuRow { header: "", app: "Files", kind: KIND_FILES },
    MenuRow { header: "Sound & Video", app: "", kind: 255 },
    MenuRow { header: "", app: "Gallery", kind: KIND_GALLERY },
    MenuRow { header: "", app: "Media Player", kind: KIND_MEDIA },
    MenuRow { header: "", app: "LingStation (music studio)", kind: KIND_DAW },
    MenuRow { header: "", app: "LingStationX (LingOS Edition)", kind: ACT_RUN_APP },
    MenuRow { header: "System", app: "", kind: 255 },
    MenuRow { header: "", app: "Packages", kind: KIND_PKG },
    MenuRow { header: "", app: "Settings", kind: KIND_SETTINGS },
    MenuRow { header: "", app: "About LingOS", kind: KIND_ABOUT },
    MenuRow { header: "Power", app: "", kind: 255 },
    MenuRow { header: "", app: "Log Out", kind: ACT_LOGOUT },
    MenuRow { header: "", app: "Restart", kind: ACT_RESTART },
    MenuRow { header: "", app: "Shut Down", kind: ACT_SHUTDOWN },
];
const MENU_W: u32 = 210;
const MENU_ROW_H: u32 = 22;
static mut MENU_OPEN: bool = false;
/// Keyboard selection cursor into the menu's *selectable* (non-header) rows,
/// in `menu_walk` order. Only meaningful while `MENU_OPEN`; the mouse path
/// ignores it (it hit-tests the pointer directly).
static mut MENU_SEL: usize = 0;

pub fn menu_open() -> bool {
    unsafe { MENU_OPEN }
}

/// Act on a chosen menu row (shared by the mouse click path and the keyboard
/// Enter path so both route identically). `255` is "nothing / a header".
fn menu_activate(kind: u8) {
    match kind {
        255 => {},
        ACT_LOGOUT => unsafe {
            crate::drivers::messenger::lock_session();
            crate::keyring::lock();
            LOGOUT_REQUESTED = true
        },
        ACT_RESTART => crate::arch::power::reboot(),
        ACT_SHUTDOWN => crate::arch::power::poweroff(),
        ACT_LAUNCH_PKG => launch_pkg(unsafe { PKG_LAUNCH }),
        ACT_RUN_APP => {
            unsafe { MENU_OPEN = false };
            crate::run_first_app();
        },
        k => open(k),
    }
}

/// How many selectable (non-header) rows the menu currently has.
fn menu_selectable_count() -> usize {
    let mut n = 0usize;
    menu_walk(|_r, _l, header, _a| {
        if !header {
            n += 1;
        }
    });
    n
}

/// The action code of the `sel`-th selectable row (sets `PKG_LAUNCH` and
/// returns `ACT_LAUNCH_PKG` for a package row, mirroring `menu_click`).
fn menu_nth_action(sel: usize) -> Option<u8> {
    let mut i = 0usize;
    let mut found: Option<u32> = None;
    menu_walk(|_row, _label, header, action| {
        if header {
            return;
        }
        if i == sel {
            found = Some(action);
        }
        i += 1;
    });
    let action = found?;
    if action >= PKG_ACTION {
        unsafe { PKG_LAUNCH = (action - PKG_ACTION) as usize };
        return Some(ACT_LAUNCH_PKG);
    }
    Some(action as u8)
}

/// Move the keyboard selection cursor, clamped to the selectable rows.
fn menu_move(delta: i32) {
    let n = menu_selectable_count() as i32;
    if n == 0 {
        return;
    }
    let cur = unsafe { MENU_SEL } as i32;
    unsafe { MENU_SEL = (cur + delta).clamp(0, n - 1) as usize };
}

/// Open the Applications menu from the keyboard: refresh the installed-package
/// rows (same as the brand-corner click) and reset the selection to the top.
fn menu_open_kbd() {
    load_installed_packages();
    unsafe {
        MENU_OPEN = true;
        MENU_SEL = 0;
    }
    mixer::jingle(mixer::EVENT_CLICK);
}

// -- Installed-package launchers -------------------------------------------

/// Does `dir` contain a file named `fname`? Tells a runnable package (one with
/// a `main.ling`) from a Rust/source-only one.
fn dir_has_file(dir: &str, fname: &[u8]) -> bool {
    let mut i = 0usize;
    loop {
        let mut nb = [0u8; 40];
        let Some((n, _)) = lingfs::list_entry(dir, i, &mut nb) else { return false };
        if &nb[..n] == fname {
            return true;
        }
        i += 1;
        if i > 256 {
            return false;
        }
    }
}

/// Extract a TOML `category = "..."` value from `toml` into `out`, returning
/// its length (0 if absent). Tolerates leading whitespace and a missing space
/// around `=`; won't false-match `categories`/`keywords`.
fn parse_toml_category(toml: &[u8], out: &mut [u8]) -> usize {
    let key = b"category";
    let n = toml.len();
    let mut i = 0usize;
    while i < n {
        let mut j = i;
        while j < n && (toml[j] == b' ' || toml[j] == b'\t') {
            j += 1;
        }
        if toml[j..].starts_with(key) {
            let mut k = j + key.len();
            while k < n && (toml[k] == b' ' || toml[k] == b'\t') {
                k += 1;
            }
            if k < n && toml[k] == b'=' {
                k += 1;
                while k < n && (toml[k] == b' ' || toml[k] == b'\t' || toml[k] == b'"') {
                    k += 1;
                }
                let s = k;
                while k < n && toml[k] != b'"' && toml[k] != b'\n' && toml[k] != b'\r' {
                    k += 1;
                }
                let val = &toml[s..k];
                let m = val.len().min(out.len());
                out[..m].copy_from_slice(&val[..m]);
                return m;
            }
        }
        while i < n && toml[i] != b'\n' {
            i += 1;
        }
        i += 1;
    }
    0
}

/// Read `<dir>/Ling.toml`'s category into `out` (0 if none/unreadable).
fn read_pkg_category(dir: &str, out: &mut [u8]) -> usize {
    let mut path = [0u8; 64];
    let mut p = 0usize;
    for &b in dir.as_bytes() {
        if p < path.len() {
            path[p] = b;
            p += 1;
        }
    }
    for &b in b"/Ling.toml" {
        if p < path.len() {
            path[p] = b;
            p += 1;
        }
    }
    let Ok(ps) = core::str::from_utf8(&path[..p]) else { return 0 };
    let mut buf = [0u8; lingfs::BLOCK_SIZE];
    let Ok(Some(len)) = lingfs::read_file(ps, &mut buf) else { return 0 };
    parse_toml_category(&buf[..len], out)
}

/// Rebuild the installed-package launcher list from lingfs. Called when the
/// start menu opens (cheap; packages are few).
fn load_installed_packages() {
    let mut count = 0usize;
    let mut i = 0usize;
    while count < MAX_MENU_PKGS {
        let mut nb = [0u8; 40];
        let Some((nlen, _)) = lingfs::list_entry("packages", i, &mut nb) else { break };
        i += 1;
        if nlen == 0 {
            continue;
        }
        let mut dir = [0u8; 48];
        let mut d = 0usize;
        for &b in b"pkg-" {
            dir[d] = b;
            d += 1;
        }
        for &b in &nb[..nlen] {
            if d < dir.len() {
                dir[d] = b;
                d += 1;
            }
        }
        let Ok(dirs) = core::str::from_utf8(&dir[..d]) else { continue };
        let mut src = [0u8; 52];
        src[..d].copy_from_slice(&dir[..d]);
        src[d..d + 4].copy_from_slice(b"/src");
        let runnable = dir_has_file(dirs, b"main.ling")
            || core::str::from_utf8(&src[..d + 4]).map(|s| dir_has_file(s, b"main.ling")).unwrap_or(false);
        if !runnable {
            continue; // source-only (Rust) package -- not runnable here
        }
        let mut cat = [0u8; 20];
        let mut clen = read_pkg_category(dirs, &mut cat);
        if clen == 0 {
            let def = b"Applications";
            clen = def.len().min(cat.len());
            cat[..clen].copy_from_slice(&def[..clen]);
        }
        unsafe {
            let nn = nlen.min(40);
            PKG_NAME[count][..nn].copy_from_slice(&nb[..nn]);
            PKG_NAME_LEN[count] = nn;
            PKG_CAT[count][..clen].copy_from_slice(&cat[..clen]);
            PKG_CAT_LEN[count] = clen;
        }
        count += 1;
    }
    unsafe { PKG_COUNT = count };
}

/// Walk every visible menu row in order -- the static built-ins with the
/// installed-package section (grouped by category) injected just before
/// "Power" -- invoking `f(row, label, is_header, action)`. `action` is
/// MENU_HEADER for a header, a window kind (<256) for a built-in app, or
/// PKG_ACTION+idx for a package. Shared by draw/click/height so they agree.
fn menu_walk(mut f: impl FnMut(usize, &[u8], bool, u32)) {
    let mut row = 0usize;
    for m in MENU.iter() {
        if m.kind == ACT_RUN_APP && crate::first_app().is_none() {
            continue; // this image bundles no compiled app
        }
        if m.header == "Power" {
            let pc = unsafe { PKG_COUNT };
            let mut drawn = [false; MAX_MENU_PKGS];
            for a in 0..pc {
                if drawn[a] {
                    continue;
                }
                let cat_a = unsafe { &PKG_CAT[a][..PKG_CAT_LEN[a]] };
                f(row, cat_a, true, MENU_HEADER);
                row += 1;
                for b in a..pc {
                    if drawn[b] {
                        continue;
                    }
                    let cat_b = unsafe { &PKG_CAT[b][..PKG_CAT_LEN[b]] };
                    if cat_b == cat_a {
                        drawn[b] = true;
                        let name = unsafe { &PKG_NAME[b][..PKG_NAME_LEN[b]] };
                        f(row, name, false, PKG_ACTION + b as u32);
                        row += 1;
                    }
                }
            }
        }
        if m.header.is_empty() {
            f(row, m.app.as_bytes(), false, m.kind as u32);
        } else {
            f(row, m.header.as_bytes(), true, MENU_HEADER);
        }
        row += 1;
    }
}

fn menu_total_rows() -> usize {
    let mut rows = 0usize;
    menu_walk(|_r, _l, _h, _a| rows += 1);
    rows
}

fn menu_rect() -> (u32, u32, u32, u32) {
    let h = menu_total_rows() as u32 * MENU_ROW_H + 30;
    (6, 31, MENU_W, h)
}

/// Brand corner (top-left of the top bar) toggles the menu.
fn brand_hit(mx: i64, my: i64) -> bool {
    my >= 0 && my < 30 && mx >= 0 && mx < 84
}

/// Returns the app kind if the press landed on an app row, ACT_LAUNCH_PKG for
/// a package row (with PKG_LAUNCH set), else 255. Closes the menu on any click.
fn menu_click(mx: i64, my: i64) -> u8 {
    let (rx, ry, rw, rh) = menu_rect();
    let inside = mx >= rx as i64 && mx < (rx + rw) as i64 && my >= ry as i64 && my < (ry + rh) as i64;
    unsafe { MENU_OPEN = false };
    if !inside {
        return 255;
    }
    let y0 = ry + 22;
    let mut hit: u32 = MENU_HEADER;
    menu_walk(|row, _label, header, action| {
        if header {
            return;
        }
        let yy = y0 + row as u32 * MENU_ROW_H;
        if my >= yy as i64 && my < (yy + MENU_ROW_H) as i64 {
            hit = action;
        }
    });
    if hit == MENU_HEADER {
        return 255;
    }
    if hit >= PKG_ACTION {
        unsafe { PKG_LAUNCH = (hit - PKG_ACTION) as usize };
        return ACT_LAUNCH_PKG;
    }
    hit as u8
}

/// Draw the applications menu (call last, over windows and dock).
pub fn draw_menu() {
    if !unsafe { MENU_OPEN } {
        return;
    }
    let (rx, ry, rw, rh) = menu_rect();
    let panel = theme::color(theme::SLOT_PANEL);
    let accent = theme::color(theme::SLOT_ACCENT);
    framebuffer::back_blend_rounded_rect(rx + 4, ry + 5, rw, rh, 10, theme::color(theme::SLOT_SHADOW), 80);
    framebuffer::back_fill_rounded_rect(rx, ry, rw, rh, 10, theme::color(theme::SLOT_PANEL_BORDER));
    framebuffer::back_fill_rounded_rect(rx + 1, ry + 1, rw - 2, rh - 2, 9, panel);
    font8x8::draw_str(rx + 12, ry + 6, b"Applications", accent, panel);
    let y0 = ry + 22;
    let text = theme::color(theme::SLOT_TEXT);
    let dim = theme::color(theme::SLOT_DIM);
    let sel = unsafe { MENU_SEL };
    let mut sidx = 0usize;
    menu_walk(|row, label, header, _action| {
        let yy = y0 + row as u32 * MENU_ROW_H;
        if header {
            font8x8::draw_str(rx + 10, yy + 6, label, dim, panel);
        } else {
            if sidx == sel {
                // Keyboard selection: an accent pill behind the row, text
                // recolored to the panel background for contrast on it.
                framebuffer::back_fill_rounded_rect(rx + 4, yy + 2, rw - 8, MENU_ROW_H - 3, 5, accent);
                font8x8::draw_str(rx + 24, yy + 6, label, panel, accent);
            } else {
                font8x8::draw_str(rx + 24, yy + 6, label, text, panel);
            }
            sidx += 1;
        }
    });
}

/// Launch installed package `idx`: open a Terminal and run its `main.ling`
/// through the in-kernel Ling interpreter.
/// Open (or raise) the Terminal and run `cmd` in it -- the Packages window
/// launching an installed app.
pub fn open_terminal_with(cmd: &str) {
    open(KIND_TERM);
    terminal::run_command(cmd);
}

fn launch_pkg(idx: usize) {
    if idx >= unsafe { PKG_COUNT } {
        return;
    }
    open(KIND_TERM);
    let mut line = [0u8; 80];
    let mut w = 0usize;
    for &b in b"ling run pkg-" {
        line[w] = b;
        w += 1;
    }
    let name = unsafe { &PKG_NAME[idx][..PKG_NAME_LEN[idx]] };
    for &b in name {
        if w < line.len() {
            line[w] = b;
            w += 1;
        }
    }
    // `ling run pkg-<name>`: the Terminal finds the entry (Ling.toml `entry`,
    // main.ling or src/main.ling).
    if let Ok(s) = core::str::from_utf8(&line[..w]) {
        terminal::run_command(s);
    }
}

// -- Browser window state --------------------------------------------------
// The URL/search bar is always visible. WEB_EDITING = the bar has focus
// (typing edits the URL); false = the page has focus (arrows scroll,
// digits follow links). A fresh browser opens with the bar focused so you
// can type a URL immediately; clicking the bar refocuses it, clicking the
// page defocuses it. No more modal `u` key.
const WEB_INPUT_MAX: usize = 200;
static mut WEB_EDITING: bool = true;
static mut WEB_INPUT: [u8; WEB_INPUT_MAX] = [0; WEB_INPUT_MAX];
static mut WEB_INPUT_LEN: usize = 0;
/// Content columns of the currently-focused browser window, captured at
/// draw time so key-driven fetches wrap to the right width.
static mut WEB_COLS: usize = 72;

fn web_key(k: u8) {
    unsafe {
        if WEB_EDITING {
            match k {
                10 => {
                    let url = core::str::from_utf8(&(&*&raw const WEB_INPUT)[..WEB_INPUT_LEN])
                        .unwrap_or("");
                    if browser::navigate(url, WEB_COLS) {
                        WEB_EDITING = false; // hand focus to the page on success
                    }
                },
                0x1B => WEB_EDITING = false,
                0x08 => {
                    if WEB_INPUT_LEN > 0 {
                        WEB_INPUT_LEN -= 1;
                    }
                },
                0x20..=0x7E => {
                    if WEB_INPUT_LEN < WEB_INPUT_MAX {
                        (&mut *&raw mut WEB_INPUT)[WEB_INPUT_LEN] = k;
                        WEB_INPUT_LEN += 1;
                    }
                },
                _ => {},
            }
            return;
        }
        match k {
            0x11 => browser::scroll(-3),                              // up
            0x12 => browser::scroll(3),                               // down
            crate::drivers::keyboard::PGUP_KEY => browser::scroll_page(-1),
            crate::drivers::keyboard::PGDN_KEY => browser::scroll_page(1),
            crate::drivers::keyboard::HOME_KEY => browser::scroll_home(),
            crate::drivers::keyboard::END_KEY => browser::scroll_end(),
            b'1'..=b'9' => {
                browser::follow((k - b'0') as usize, WEB_COLS);
            },
            // Any printable key refocuses the URL bar and starts a fresh
            // edit (Chrome-style "just start typing").
            0x20..=0x7E => {
                WEB_EDITING = true;
                WEB_INPUT_LEN = 0;
                (&mut *&raw mut WEB_INPUT)[0] = k;
                WEB_INPUT_LEN = 1;
            },
            _ => {},
        }
    }
}

// -- Horizon window state ---------------------------------------------------
// Same shape as the bring browser's URL bar above (WEB_*): HZ_EDITING =
// the bar has focus; false = the page has focus (arrows scroll, digits
// follow links).
const HZ_INPUT_MAX: usize = 200;
static mut HZ_EDITING: bool = true;
static mut HZ_INPUT: [u8; HZ_INPUT_MAX] = [0; HZ_INPUT_MAX];
static mut HZ_INPUT_LEN: usize = 0;
/// Content width (px) of the currently-focused horizon window, captured at
/// draw time so key-driven fetches lay out at the right viewport width.
static mut HZ_VIEWPORT_W: u32 = 560;

fn horizon_key(k: u8) {
    unsafe {
        if HZ_EDITING {
            match k {
                10 => {
                    let url = core::str::from_utf8(&(&*&raw const HZ_INPUT)[..HZ_INPUT_LEN]).unwrap_or("");
                    if horizon::navigate(url, HZ_VIEWPORT_W) {
                        HZ_EDITING = false;
                    }
                },
                0x1B => HZ_EDITING = false,
                0x08 => {
                    if HZ_INPUT_LEN > 0 {
                        HZ_INPUT_LEN -= 1;
                    }
                },
                0x20..=0x7E => {
                    if HZ_INPUT_LEN < HZ_INPUT_MAX {
                        (&mut *&raw mut HZ_INPUT)[HZ_INPUT_LEN] = k;
                        HZ_INPUT_LEN += 1;
                    }
                },
                _ => {},
            }
            return;
        }
        match k {
            0x11 => horizon::scroll(-3),
            0x12 => horizon::scroll(3),
            crate::drivers::keyboard::PGUP_KEY => horizon::scroll_page(-1),
            crate::drivers::keyboard::PGDN_KEY => horizon::scroll_page(1),
            crate::drivers::keyboard::HOME_KEY => horizon::scroll_home(),
            crate::drivers::keyboard::END_KEY => horizon::scroll_end(),
            b'1'..=b'9' => {
                horizon::follow((k - b'0') as usize, HZ_VIEWPORT_W);
            },
            0x20..=0x7E => {
                HZ_EDITING = true;
                HZ_INPUT_LEN = 0;
                (&mut *&raw mut HZ_INPUT)[0] = k;
                HZ_INPUT_LEN = 1;
            },
            _ => {},
        }
    }
}

// -- Files window state ----------------------------------------------------
const FM_NAME_MAX: usize = 60;
/// Longest folder path the Files window tracks (home/<user>/3D Models/...).
const FM_PATH_MAX: usize = 128;
static mut FM_DIR: [u8; FM_PATH_MAX] = [0; FM_PATH_MAX]; // "" = root
static mut FM_DIR_LEN: usize = 0;
static mut FM_CURSOR: usize = 0;
static mut FM_SCROLL: usize = 0;
static mut FM_VIEWING: bool = false; // true = file-content view
static mut FM_VIEW_NAME: [u8; FM_PATH_MAX] = [0; FM_PATH_MAX];
/// One-line notice under the listing (e.g. a format that can't be opened yet).
static mut FM_MSG: &str = "";
/// (time ms, row) of the last click, for double-click.
static mut FM_CLICK: (u64, usize) = (0, usize::MAX);
/// The current folder's entries in display order (home folders first, in
/// `users::HOME_FOLDERS` order), rebuilt when the folder or the disk changes.
static mut FM_ITEMS: alloc::vec::Vec<(alloc::string::String, bool)> = alloc::vec::Vec::new();
static mut FM_ITEMS_KEY: (u32, usize, u64) = (u32::MAX, 0, 0);
static mut FM_VIEW_NAME_LEN: usize = 0;
const FM_VISIBLE_ROWS: usize = 10;

// -- Wallpaper picker (KIND_WALLPICK) --------------------------------------
// Two panes: shortcuts (drives/common folders) on the left, the current
// folder's entries on the right. Tab switches panes; Enter opens a folder or
// sets a .bmp as the wallpaper. lingfs is one "drive" (root), so the shortcuts
// are the root + the common top-level folders.
const WP_SHORTCUTS: [(&str, &str); 5] = [
    ("Root /", ""),
    ("Users", "users"),
    ("Music", "music"),
    ("Dev", "dev"),
    ("Packages", "packages"),
];
static mut WP_DIR: [u8; FM_NAME_MAX] = [0; FM_NAME_MAX]; // "" = root
static mut WP_DIR_LEN: usize = 0;
static mut WP_PANE: u8 = 0; // 0 = shortcuts (left), 1 = files (right)
static mut WP_LEFT: usize = 0; // left-pane cursor
static mut WP_RIGHT: usize = 0; // right-pane cursor
static mut WP_SCROLL: usize = 0; // right-pane scroll
static mut WP_STATUS: &str = "";
const WP_VISIBLE: usize = 12;

fn wp_dir() -> &'static str {
    unsafe { core::str::from_utf8(&(&*&raw const WP_DIR)[..WP_DIR_LEN]).unwrap_or("") }
}

fn wp_set_dir(name: &str) {
    unsafe {
        let n = name.len().min(FM_NAME_MAX);
        (&mut *&raw mut WP_DIR)[..n].copy_from_slice(&name.as_bytes()[..n]);
        WP_DIR_LEN = n;
        WP_RIGHT = 0;
        WP_SCROLL = 0;
    }
}

fn wp_entry_count() -> usize {
    let mut n = 0;
    let mut buf = [0u8; FM_NAME_MAX];
    while n < 128 {
        if lingfs::list_entry(wp_dir(), n, &mut buf).is_none() {
            break;
        }
        n += 1;
    }
    n
}

/// Open the wallpaper picker (from Settings > Wallpaper). Resets to root.
pub fn open_wallpaper_picker() {
    unsafe {
        WP_DIR_LEN = 0;
        WP_PANE = 0;
        WP_LEFT = 0;
        WP_RIGHT = 0;
        WP_SCROLL = 0;
        WP_STATUS = "tab: pane   enter: open / set   backspace: up";
    }
    open(KIND_WALLPICK);
}

fn wallpick_key(k: u8) {
    unsafe {
        match k {
            0x09 => WP_PANE = 1 - WP_PANE, // Tab: switch pane
            0x11 => {
                if WP_PANE == 0 {
                    WP_LEFT = WP_LEFT.saturating_sub(1);
                } else {
                    WP_RIGHT = WP_RIGHT.saturating_sub(1);
                }
            },
            0x12 => {
                if WP_PANE == 0 {
                    WP_LEFT = (WP_LEFT + 1).min(WP_SHORTCUTS.len() - 1);
                } else {
                    let c = wp_entry_count();
                    if c > 0 {
                        WP_RIGHT = (WP_RIGHT + 1).min(c - 1);
                    }
                }
            },
            0x08 | 0x13 => {
                // Backspace / left: up to root (lingfs dirs are one level deep).
                if WP_PANE == 1 && WP_DIR_LEN > 0 {
                    WP_DIR_LEN = 0;
                    WP_RIGHT = 0;
                    WP_SCROLL = 0;
                } else {
                    WP_PANE = 0;
                }
            },
            10 => {
                if WP_PANE == 0 {
                    // Jump the right pane to the chosen shortcut folder.
                    wp_set_dir(WP_SHORTCUTS[WP_LEFT].1);
                    WP_PANE = 1;
                } else {
                    let mut name = [0u8; FM_NAME_MAX];
                    if let Some((len, is_dir)) = lingfs::list_entry(wp_dir(), WP_RIGHT, &mut name) {
                        if is_dir && WP_DIR_LEN == 0 {
                            wp_set_dir(core::str::from_utf8(&name[..len]).unwrap_or(""));
                        } else if !is_dir && len >= 4 && &name[len - 4..len] == b".bmp" {
                            // Compose "dir/name" (or "name" at root) + set it.
                            let mut full = [0u8; FM_NAME_MAX * 2 + 1];
                            let mut off = 0;
                            let dir = wp_dir();
                            if !dir.is_empty() {
                                full[..dir.len()].copy_from_slice(dir.as_bytes());
                                off = dir.len();
                                full[off] = b'/';
                                off += 1;
                            }
                            full[off..off + len].copy_from_slice(&name[..len]);
                            let path = core::str::from_utf8(&full[..off + len]).unwrap_or("");
                            if set_wallpaper_image(path) {
                                settings_save();
                                // Close the picker window.
                                for i in 0..MAX_WINDOWS {
                                    if windows()[i].used && windows()[i].kind == KIND_WALLPICK {
                                        close(i);
                                    }
                                }
                            } else {
                                WP_STATUS = "couldn't load that BMP";
                            }
                        } else {
                            WP_STATUS = "select a .bmp (or a folder)";
                        }
                    }
                }
            },
            _ => {},
        }
        // Keep the right-pane cursor visible.
        if WP_RIGHT < WP_SCROLL {
            WP_SCROLL = WP_RIGHT;
        }
        if WP_RIGHT >= WP_SCROLL + WP_VISIBLE {
            WP_SCROLL = WP_RIGHT + 1 - WP_VISIBLE;
        }
    }
}

fn windows() -> &'static mut [Window; MAX_WINDOWS] {
    unsafe { &mut *&raw mut WINDOWS }
}

/// Serial hex logger (same shape as net_e1000's private one -- console
/// output mirrors to serial regardless of display mode, so this is
/// screendump-independent diagnostics for the input path).
fn log_hex(label: &[u8], v: u32) {
    crate::console_write(label);
    let hex = b"0123456789ABCDEF";
    let mut buf = [0u8; 8];
    for i in 0..8 {
        buf[i] = hex[((v >> ((7 - i) * 4)) & 0xF) as usize];
    }
    crate::console_write(&buf);
    crate::console_write(b"\n");
}

fn kind_title(kind: u8) -> &'static str {
    match kind {
        KIND_ABOUT => "About LingOS",
        KIND_SETTINGS => "Settings",
        KIND_FILES => "Files",
        KIND_WEB => "bring - browser in ling",
        KIND_HORIZON => "horizon - box-model browser",
        KIND_MESSENGER => "Messenger",
        KIND_EDIT => "Editor",
        KIND_GALLERY => "Gallery",
        KIND_TERM => "Terminal",
        KIND_MEDIA => "Media Player",
        KIND_PKG => "Packages",
        KIND_WALLPICK => "Choose wallpaper",
        KIND_DAW => "LingStation",
        _ => "?",
    }
}

fn kind_size(kind: u8) -> (u32, u32) {
    // Real pixels, scaled off the framebuffer so windows stay proportionate
    // (ui_scale's virtual canvas is for full-screen layouts; windows scale
    // against screen height directly).
    let s = (framebuffer::height() as f64 / 600.0).max(0.5);
    let (w, h) = match kind {
        KIND_SETTINGS => (470.0, 470.0),
        KIND_FILES => (470.0, 370.0),
        KIND_WEB => (620.0, 460.0),
        KIND_HORIZON => (620.0, 460.0),
        KIND_MESSENGER => (560.0, 420.0),
        KIND_EDIT => (600.0, 440.0),
        KIND_GALLERY => (560.0, 440.0),
        KIND_TERM => (620.0, 420.0),
        KIND_MEDIA => (480.0, 300.0),
        KIND_PKG => (560.0, 440.0),
        KIND_WALLPICK => (640.0, 460.0),
        KIND_DAW => (900.0, 680.0),
        _ => (390.0, 250.0),
    };
    ((w * s) as u32, (h * s) as u32)
}

/// Single-glyph dock abbreviation, in the current UI language's script where a
/// baked glyph exists (Chinese/Korean), else the Latin initial. `bring` (the
/// web browser) stays "W" -- it's a product name, not a translatable word.
pub fn dock_letter(i: usize) -> &'static str {
    let kind = DOCK_APPS.get(i).copied();
    let id = locale::selected().and_then(|s| locale::get(s)).map(|l| l.id).unwrap_or("en-US");
    match id {
        "zh-CN" => match kind {
            Some(KIND_ABOUT) => "关",
            Some(KIND_SETTINGS) => "设",
            Some(KIND_FILES) => "文",
            Some(KIND_WEB) => "网",
            Some(KIND_EDIT) => "编",
            Some(KIND_GALLERY) => "图",
            Some(KIND_TERM) => "终",
            Some(KIND_MEDIA) => "媒",
            Some(KIND_DAW) => "L",
            _ => "",
        },
        "ko-KR" => match kind {
            Some(KIND_ABOUT) => "정",
            Some(KIND_SETTINGS) => "설",
            Some(KIND_FILES) => "파",
            Some(KIND_WEB) => "웹",
            Some(KIND_EDIT) => "편",
            Some(KIND_GALLERY) => "갤",
            Some(KIND_TERM) => "터",
            Some(KIND_MEDIA) => "미",
            Some(KIND_DAW) => "L",
            _ => "",
        },
        _ => match kind {
            Some(KIND_ABOUT) => "i",
            Some(KIND_SETTINGS) => "S",
            Some(KIND_FILES) => "F",
            Some(KIND_WEB) => "W",
            Some(KIND_EDIT) => "E",
            Some(KIND_GALLERY) => "G",
            Some(KIND_TERM) => "T",
            Some(KIND_MEDIA) => "M",
            Some(KIND_DAW) => "L",
            _ => "",
        },
    }
}

/// Draw the dock abbreviation centered in the icon, kernel-side so CJK glyphs
/// (16px, via `font_unicode`) and Latin initials (8px * DPI) are both centered
/// correctly -- `.ling` can't measure a UTF-8 string's pixel width to center
/// it itself.
pub fn draw_dock_glyph(i: usize, dx: u32, dy: u32, sz: u32) {
    let s = dock_letter(i);
    let b = s.as_bytes();
    if b.is_empty() {
        return;
    }
    let wide = b[0] >= 0x80; // a baked 16px CJK/Hangul glyph vs an ASCII initial
    let gw = if wide { 16 } else { font8x8::advance() };
    let gh = if wide { 16 } else { 8 * font8x8::scale() };
    let gx = dx + sz.saturating_sub(gw) / 2;
    let gy = dy + sz.saturating_sub(gh) / 2;
    font_unicode::draw_utf8_str(gx, gy, b, theme::color(3), theme::color(1), false);
}

// -- Dock geometry (real pixels, computed from the live fb size) -----------

pub fn dock_count() -> usize {
    DOCK_APPS.len()
}

pub fn dock_icon_size() -> u32 {
    (framebuffer::height() / 14).max(32)
}

fn dock_gap() -> u32 {
    dock_icon_size() / 4
}

pub fn dock_y() -> u32 {
    framebuffer::height().saturating_sub(dock_icon_size() + dock_icon_size() / 2)
}

pub fn dock_x(i: usize) -> u32 {
    let n = DOCK_APPS.len() as u32;
    let total = n * dock_icon_size() + (n - 1) * dock_gap();
    let start = (framebuffer::width().saturating_sub(total)) / 2;
    start + i as u32 * (dock_icon_size() + dock_gap())
}

pub fn dock_hover(i: usize) -> bool {
    unsafe { HOVER_DOCK == i as i32 }
}

/// Is a window of this dock slot's kind open (running indicator dot)?
pub fn dock_running(i: usize) -> bool {
    let Some(&kind) = DOCK_APPS.get(i) else { return false };
    windows().iter().any(|w| w.used && w.kind == kind)
}

fn find_kind(kind: u8) -> Option<usize> {
    windows().iter().position(|w| w.used && w.kind == kind)
}

fn z_remove(idx: usize) {
    unsafe {
        let z = &mut *&raw mut Z;
        let mut j = 0;
        for i in 0..Z_LEN {
            if z[i] != idx {
                z[j] = z[i];
                j += 1;
            }
        }
        Z_LEN = j;
    }
}

fn z_raise(idx: usize) {
    z_remove(idx);
    unsafe {
        let z = &mut *&raw mut Z;
        if Z_LEN < MAX_WINDOWS {
            z[Z_LEN] = idx;
            Z_LEN += 1;
        }
    }
}

pub fn open(kind: u8) {
    if let Some(idx) = find_kind(kind) {
        windows()[idx].minimized = false;
        z_raise(idx);
        return;
    }
    let Some(idx) = windows().iter().position(|w| !w.used) else { return };
    if kind == KIND_FILES {
        fm_go_home();
    }
    let (w, h) = kind_size(kind);
    let cascade = unsafe {
        SPAWN_COUNT = SPAWN_COUNT.wrapping_add(1);
        (SPAWN_COUNT % 5) as f64 * 36.0
    };
    let cx = (framebuffer::width().saturating_sub(w) / 2) as f64 + cascade;
    // Never spawn under the top bar (30px panel + 1px divider).
    let cy = ((framebuffer::height().saturating_sub(h) / 3) as f64 + cascade).max(40.0);
    windows()[idx] = Window {
        used: true,
        minimized: false,
        kind,
        w,
        h,
        // Spawn springing up from the dock: start slightly below and
        // scaled-down feel comes free from the spring overshoot.
        px: cx,
        py: cy + 60.0,
        vx: 0.0,
        vy: -0.8,
        tx: cx,
        ty: cy,
        scale_w: 1.0,
        scale_h: 1.0,
        bend: 0.0,
        closing: false,
        dissolve: 0.0,
        maximized: false,
        sx: 0.0,
        sy: 0.0,
        sw: 0,
        sh: 0,
    };
    z_raise(idx);
    // The package manager syncs its catalog when it first appears (bounded
    // HTTP; fast-fail if no repo). Done here, not in draw, so the network
    // wait happens once at open rather than every frame.
    if kind == KIND_PKG {
        pkgman::open();
    }
    mixer::jingle(mixer::EVENT_OPEN);
}

/// Begin the dissolve-on-close animation. The window keeps its slot and
/// its z position (so it sublimates in place, over lower windows) until
/// `step` finalizes it -- see the Window::dissolve doc.
pub fn close(idx: usize) {
    if idx < MAX_WINDOWS && windows()[idx].used && !windows()[idx].closing {
        windows()[idx].closing = true;
        windows()[idx].dissolve = 0.0001; // nonzero so draw enters the sublimate path
        unsafe {
            if DRAG_WIN == idx as i32 {
                DRAG_WIN = -1;
            }
            if RESIZE_WIN == idx as i32 {
                RESIZE_WIN = -1;
            }
        }
        mixer::jingle(mixer::EVENT_CLOSE);
    }
}

fn finalize_closed(idx: usize) {
    windows()[idx].used = false;
    windows()[idx].closing = false;
    z_remove(idx);
}

fn minimize(idx: usize) {
    if idx < MAX_WINDOWS && windows()[idx].used {
        windows()[idx].minimized = true;
        z_remove(idx);
        unsafe {
            if DRAG_WIN == idx as i32 {
                DRAG_WIN = -1;
            }
            if RESIZE_WIN == idx as i32 {
                RESIZE_WIN = -1;
            }
        }
    }
}

/// Deformed (squash/stretch-applied) rect of a window, centered on its own
/// middle -- the same center-scale the old `.ling` loop computed by hand.
fn deformed_rect(w: &Window) -> (i64, i64, u32, u32) {
    let dw = (w.w as f64 * w.scale_w) as i64;
    let dh = (w.h as f64 * w.scale_h) as i64;
    let x = w.px as i64 - (dw - w.w as i64) / 2;
    let y = w.py as i64 - (dh - w.h as i64) / 2;
    (x, y, dw.max(0) as u32, dh.max(0) as u32)
}

fn hit_window(w: &Window, mx: i64, my: i64) -> bool {
    // A dissolving window is inert -- clicks fall through to whatever's
    // beneath it.
    if w.closing {
        return false;
    }
    let (x, y, dw, dh) = deformed_rect(w);
    mx >= x && mx < x + dw as i64 && my >= y && my < y + dh as i64
}

fn hit_titlebar(w: &Window, mx: i64, my: i64) -> bool {
    let (x, y, dw, _) = deformed_rect(w);
    mx >= x && mx < x + dw as i64 && my >= y && my < y + TITLEBAR_H as i64
}

fn hit_close(w: &Window, mx: i64, my: i64) -> bool {
    // Close button: right end of the titlebar, a TITLEBAR_H-sized square.
    let (x, y, dw, _) = deformed_rect(w);
    let bx = x + dw as i64 - TITLEBAR_H as i64;
    mx >= bx && mx < x + dw as i64 && my >= y && my < y + TITLEBAR_H as i64
}

fn hit_minimize(w: &Window, mx: i64, my: i64) -> bool {
    let (x, y, dw, _) = deformed_rect(w);
    let bx = x + dw as i64 - 2 * TITLEBAR_H as i64;
    mx >= bx && mx < x + dw as i64 - TITLEBAR_H as i64 && my >= y && my < y + TITLEBAR_H as i64
}

fn hit_maximize(w: &Window, mx: i64, my: i64) -> bool {
    // Third button in from the right (green), left of minimize.
    let (x, y, dw, _) = deformed_rect(w);
    let bx = x + dw as i64 - 3 * TITLEBAR_H as i64;
    mx >= bx && mx < x + dw as i64 - 2 * TITLEBAR_H as i64 && my >= y && my < y + TITLEBAR_H as i64
}

// Resize edge bitmask.
const RESIZE_LEFT: u8 = 1;
const RESIZE_RIGHT: u8 = 2;
const RESIZE_BOTTOM: u8 = 8;
const RESIZE_MARGIN: i64 = 6;
const WIN_MIN_W: u32 = 240;
const WIN_MIN_H: u32 = 160;

/// Which resize edge(s) the cursor is on, or 0. Grabbable band is the inner
/// `RESIZE_MARGIN` px of the left/right/bottom borders (top is the titlebar,
/// which drags). A maximized or dissolving window isn't resizable.
fn hit_resize(w: &Window, mx: i64, my: i64) -> u8 {
    if w.closing || w.maximized {
        return 0;
    }
    let (x, y, dw, dh) = deformed_rect(w);
    let (dw, dh) = (dw as i64, dh as i64);
    if mx < x || mx > x + dw || my < y || my > y + dh {
        return 0;
    }
    let mut m = 0u8;
    if mx - x <= RESIZE_MARGIN {
        m |= RESIZE_LEFT;
    }
    if (x + dw) - mx <= RESIZE_MARGIN {
        m |= RESIZE_RIGHT;
    }
    if (y + dh) - my <= RESIZE_MARGIN {
        m |= RESIZE_BOTTOM;
    }
    // The bottom band must be below the titlebar to be a resize (not a
    // titlebar-height sliver on a tiny window).
    if m & RESIZE_BOTTOM != 0 && my < y + TITLEBAR_H as i64 {
        m &= !RESIZE_BOTTOM;
    }
    m
}

/// Maximize fills the workspace (below the top bar, above the dock); toggling
/// again restores the saved floating geometry.
fn toggle_maximize(idx: usize) {
    let fw = framebuffer::width();
    let top = TITLEBAR_MARGIN_TOP;
    let wsh = dock_y().saturating_sub(top).saturating_sub(12).max(WIN_MIN_H);
    let w = &mut windows()[idx];
    if w.maximized {
        w.w = w.sw;
        w.h = w.sh;
        w.px = w.sx;
        w.py = w.sy;
        w.tx = w.sx;
        w.ty = w.sy;
        w.maximized = false;
    } else {
        w.sx = w.px;
        w.sy = w.py;
        w.sw = w.w;
        w.sh = w.h;
        w.px = 0.0;
        w.py = top as f64;
        w.tx = 0.0;
        w.ty = top as f64;
        w.w = fw;
        w.h = wsh;
        w.maximized = true;
    }
    w.vx = 0.0;
    w.vy = 0.0;
    w.scale_w = 1.0;
    w.scale_h = 1.0;
    z_raise(idx);
}

/// Top of the workspace: below the 30px top bar + 1px divider.
const TITLEBAR_MARGIN_TOP: u32 = 31;

/// Live snap-zone preview: where the currently-dragged window would dock if
/// released now (`None` = not in a zone). Drawn as a translucent ghost under
/// the dragged window (see `draw_window`).
static mut SNAP_PREVIEW: Option<(u32, u32, u32, u32)> = None;

/// Aero-snap target for a titlebar drag whose cursor is at `(mx, my)`: screen
/// edges dock to half the workspace, corners to a quarter, the very top edge
/// maximizes. Returns `(rect, is_full_maximize)` or `None` outside any zone.
fn snap_target(mx: i64, my: i64) -> Option<((f64, f64, u32, u32), bool)> {
    let fw = framebuffer::width();
    let top = TITLEBAR_MARGIN_TOP;
    let dock = dock_y();
    if dock <= top + 60 {
        return None;
    }
    let workh = dock.saturating_sub(top).saturating_sub(6); // gap above the dock
    let halfw = fw / 2;
    let halfh = workh / 2;
    let e: i64 = 14; // edge trigger band
    let corner: i64 = 90; // how far along an edge still counts as a corner
    let near_left = mx <= e;
    let near_right = mx >= fw as i64 - e;
    let near_top = my <= top as i64 + corner;
    let near_bottom = my >= (top + workh) as i64 - corner;
    let r = |x: u32, y: u32, w: u32, h: u32, full: bool| Some(((x as f64, y as f64, w, h), full));
    if near_left && near_top {
        return r(0, top, halfw, halfh, false); // top-left quarter
    }
    if near_left && near_bottom {
        return r(0, top + halfh, halfw, workh - halfh, false); // bottom-left quarter
    }
    if near_right && near_top {
        return r(halfw, top, fw - halfw, halfh, false); // top-right quarter
    }
    if near_right && near_bottom {
        return r(halfw, top + halfh, fw - halfw, workh - halfh, false); // bottom-right quarter
    }
    if near_left {
        return r(0, top, halfw, workh, false); // left half
    }
    if near_right {
        return r(halfw, top, fw - halfw, workh, false); // right half
    }
    if my <= top as i64 + e {
        return r(0, top, fw, workh, true); // top edge -> maximize
    }
    None
}

/// Move+resize a window into a snap rect. `full` marks a maximize (so it
/// reflows on a live resolution change; half/quarter snaps stay put).
fn snap_apply(idx: usize, rect: (f64, f64, u32, u32), full: bool) {
    let w = &mut windows()[idx];
    w.px = rect.0;
    w.py = rect.1;
    w.tx = rect.0;
    w.ty = rect.1;
    w.w = rect.2;
    w.h = rect.3;
    w.maximized = full;
    w.vx = 0.0;
    w.vy = 0.0;
    w.scale_w = 1.0;
    w.scale_h = 1.0;
    z_raise(idx);
}

fn dock_hit(mx: i64, my: i64) -> i32 {
    let size = dock_icon_size() as i64;
    let y = dock_y() as i64;
    if my < y || my >= y + size {
        return -1;
    }
    for i in 0..DOCK_APPS.len() {
        let x = dock_x(i) as i64;
        if mx >= x && mx < x + size {
            return i as i32;
        }
    }
    -1
}

/// One frame of window-system logic: click routing (dock, close/minimize
/// buttons, titlebar drag starts, raise-on-click), drag tracking, and the
/// per-window spring advance. Call once per frame from the `.ling` loop,
/// before reading any slot getter.
static mut SETTINGS_LOADED: bool = false;
static mut SEED_DONE: bool = false;
static mut BOOT_MUSIC_STARTED: bool = false;

/// Read lingfs `/dns` (two dotted-quad lines: primary, secondary) and
/// apply it, so DNS is configurable without a rebuild. Absent/garbled =
/// keep the compiled defaults (192.168.0.2 -> Cloudflare 1.1.1.1).
fn load_dns_config() {
    let mut buf = [0u8; crate::fs::lingfs::BLOCK_SIZE];
    let Ok(Some(len)) = lingfs::read_file("dns", &mut buf) else { return };
    let mut it = buf[..len].split(|&b| b == b'\n' || b == b' ' || b == b'\r').filter(|s| !s.is_empty());
    let parse = |s: &[u8]| -> Option<[u8; 4]> {
        let mut ip = [0u8; 4];
        let mut part = 0;
        let mut acc: u32 = 0;
        let mut any = false;
        for &b in s {
            match b {
                b'0'..=b'9' => {
                    acc = acc * 10 + (b - b'0') as u32;
                    any = true;
                    if acc > 255 {
                        return None;
                    }
                },
                b'.' => {
                    if part >= 3 || !any {
                        return None;
                    }
                    ip[part] = acc as u8;
                    part += 1;
                    acc = 0;
                    any = false;
                },
                _ => return None,
            }
        }
        if part == 3 && any {
            ip[3] = acc as u8;
            Some(ip)
        } else {
            None
        }
    };
    let (mut p, mut s) = netstack::dns_servers();
    if let Some(v) = it.next().and_then(parse) {
        p = v;
    }
    if let Some(v) = it.next().and_then(parse) {
        s = v;
    }
    netstack::set_dns(p, s);
}

pub fn step(mx: i64, my: i64, buttons: u8) {
    unsafe {
        // Restore persisted settings on the first frame (lingfs is mounted
        // by the time the desktop loop starts). Lazy so no extra intrinsic
        // / compiler rebuild is needed just to call settings_load.
        if !SETTINGS_LOADED {
            SETTINGS_LOADED = true;
            settings_load();
            load_dns_config();
            // Re-apply the persisted live resolution (DISPI adapters) so an
            // installed system comes back up in the mode you last chose.
            display::restore();
        }
        // Seed the music library incrementally -- at most one song per
        // frame, so the ~1.5MiB write never freezes a single frame.
        if !SEED_DONE {
            SEED_DONE = media::seed_pelipo();
        }
        // Start the Seraph startup loop once, as the desktop comes up (the mixer
        // + AC97 pump are live by the first frame). Loops as background music
        // until the Media player takes over the PCM channel.
        if !BOOT_MUSIC_STARTED {
            BOOT_MUSIC_STARTED = true;
            media::play_boot_loop();
        }
        // Service a pending package-manager sync here (not on the open path):
        // the pkg window's "syncing…" frame was presented last frame, so the
        // blocking fetch now runs with real on-screen feedback, and the mouse
        // is resynced afterward. No-op on frames with nothing pending.
        pkgman::service();
        // Package icons load in a background cooperative task rather than
        // inline: spawn it once, then yield to it each frame so it can fetch +
        // decode avatars while the desktop keeps rendering. The network waits
        // inside the fetch yield back to us (timer::poll_until), so a large
        // download never freezes the loop -- only a brief PNG decode does.
        pkgman::ensure_icon_task();
        crate::proc::sched::yield_now();
        // Serial diagnostic, once every 512 frames: proves whether IRQ12
        // bytes are flowing and where the driver thinks the cursor is --
        // the framebuffer regression lesson applied to input (a dead mouse
        // looks identical to a hit-test bug on a screendump).
        FRAME_NO = FRAME_NO.wrapping_add(1);
        if FRAME_NO % 512 == 0 {
            log_hex(b"wm: mouse bytes=0x", crate::drivers::mouse::byte_count());
            log_hex(b"wm: last=0x", crate::drivers::mouse::last_byte() as u32);
            // 8042 status: bit0 OBF, bit5 aux-origin -- OBF stuck high with
            // a flat byte count means data is waiting but IRQ12 never
            // fires; OBF low means the mouse isn't streaming at all.
            log_hex(b"wm: 8042=0x", crate::arch::io::inb(0x64) as u32);
            log_hex(b"wm: mx=0x", mx as u32);
        }
        let left = buttons & 1 != 0;
        let was = PREV_BUTTONS & 1 != 0;
        PREV_BUTTONS = buttons;

        HOVER_DOCK = dock_hit(mx, my);

        if left && !was {
            // Press edge, overlay-priority order: an open app menu first,
            // then the brand corner that toggles it, then tray, dock,
            // windows.
            if MENU_OPEN {
                let kind = menu_click(mx, my);
                menu_activate(kind);
                PREV_BUTTONS = buttons;
                return;
            }
            if brand_hit(mx, my) {
                load_installed_packages();
                MENU_OPEN = true;
                mixer::jingle(mixer::EVENT_CLICK);
                PREV_BUTTONS = buttons;
                return;
            }
            if tray_hit(mx, my) {
                PREV_BUTTONS = buttons;
                return;
            }
            let dock = HOVER_DOCK;
            if dock >= 0 {
                mixer::jingle(mixer::EVENT_CLICK);
                let kind = DOCK_APPS[dock as usize];
                match find_kind(kind) {
                    Some(idx) if windows()[idx].minimized => {
                        windows()[idx].minimized = false;
                        z_raise(idx);
                    },
                    Some(idx) if Z_LEN > 0 && Z[Z_LEN - 1] == idx => minimize(idx),
                    Some(idx) => z_raise(idx),
                    None => open(kind),
                }
            } else {
                // Topmost window under the cursor wins.
                let z = &*&raw const Z;
                let mut clicked: i32 = -1;
                for zi in (0..Z_LEN).rev() {
                    let idx = z[zi];
                    if hit_window(&windows()[idx], mx, my) {
                        clicked = idx as i32;
                        break;
                    }
                }
                if clicked >= 0 {
                    let idx = clicked as usize;
                    if hit_close(&windows()[idx], mx, my) {
                        close(idx);
                    } else if hit_minimize(&windows()[idx], mx, my) {
                        minimize(idx);
                    } else if hit_maximize(&windows()[idx], mx, my) {
                        toggle_maximize(idx);
                    } else {
                        z_raise(idx);
                        let edge = hit_resize(&windows()[idx], mx, my);
                        if edge != 0 {
                            // Begin an edge/corner resize: remember the grab.
                            RESIZE_WIN = idx as i32;
                            RESIZE_EDGE = edge;
                            RES_MX0 = mx as f64;
                            RES_MY0 = my as f64;
                            RES_PX0 = windows()[idx].px;
                            RES_PY0 = windows()[idx].py;
                            RES_W0 = windows()[idx].w;
                            RES_H0 = windows()[idx].h;
                        } else if hit_titlebar(&windows()[idx], mx, my) {
                            // Dragging a maximized window un-maximizes it and
                            // re-anchors the restored window under the cursor.
                            if windows()[idx].maximized {
                                toggle_maximize(idx);
                                let neww = windows()[idx].w as f64;
                                windows()[idx].px = (mx as f64 - neww / 2.0).max(0.0);
                                windows()[idx].py = TITLEBAR_MARGIN_TOP as f64 + 3.0;
                                windows()[idx].tx = windows()[idx].px;
                                windows()[idx].ty = windows()[idx].py;
                            }
                            DRAG_WIN = idx as i32;
                            GRAB_DX = mx as f64 - windows()[idx].px;
                            GRAB_DY = my as f64 - windows()[idx].py;
                        } else {
                            // Click in the content area -- let the app
                            // handle it (Settings Apply/OK buttons today).
                            content_click(idx, mx, my);
                        }
                    }
                } else {
                    log_hex(b"wm: click missed, my=0x", my as u32);
                }
            }
        }

        // Release: a titlebar drag let go in an edge/corner snap zone docks
        // the window (aero-snap); otherwise the drag/resize just ends.
        if !left {
            if DRAG_WIN >= 0 {
                let idx = DRAG_WIN as usize;
                if let Some((rect, full)) = snap_target(mx, my) {
                    snap_apply(idx, rect, full);
                }
            }
            DRAG_WIN = -1;
            RESIZE_WIN = -1;
            RESIZE_EDGE = 0;
            SNAP_PREVIEW = None;
        }

        // Edge/corner resize tracking: compute the new geometry as a delta from
        // the grab, keeping the opposite edge anchored.
        if RESIZE_WIN >= 0 && left {
            let idx = RESIZE_WIN as usize;
            let edge = RESIZE_EDGE;
            let fw = framebuffer::width() as f64;
            let dxp = mx as f64 - RES_MX0;
            let dyp = my as f64 - RES_MY0;
            let mut new_px = RES_PX0;
            let mut new_w = RES_W0 as f64;
            let mut new_h = RES_H0 as f64;
            if edge & RESIZE_RIGHT != 0 {
                new_w = (RES_W0 as f64 + dxp).max(WIN_MIN_W as f64);
            }
            if edge & RESIZE_LEFT != 0 {
                new_w = (RES_W0 as f64 - dxp).max(WIN_MIN_W as f64);
                new_px = RES_PX0 + (RES_W0 as f64 - new_w); // keep right edge fixed
            }
            if edge & RESIZE_BOTTOM != 0 {
                new_h = (RES_H0 as f64 + dyp).max(WIN_MIN_H as f64);
            }
            new_px = new_px.max(0.0);
            new_w = new_w.min(fw - new_px);
            let wref = &mut windows()[idx];
            wref.w = new_w as u32;
            wref.h = new_h as u32;
            wref.px = new_px;
            wref.tx = new_px;
            wref.vx = 0.0;
            wref.vy = 0.0;
            wref.scale_w = 1.0;
            wref.scale_h = 1.0;
            wref.maximized = false; // a manual resize un-docks
        }

        // Titlebar move tracking + live snap preview.
        if DRAG_WIN >= 0 {
            let idx = DRAG_WIN as usize;
            let w = windows()[idx].w as f64;
            // Clamp so the titlebar always stays reachable: below the top
            // bar, at least 60px of window on-screen horizontally, and the
            // titlebar itself never below the bottom edge.
            let max_x = framebuffer::width() as f64 - 60.0;
            let max_y = framebuffer::height() as f64 - TITLEBAR_H as f64;
            windows()[idx].tx = (mx as f64 - GRAB_DX).max(60.0 - w).min(max_x);
            windows()[idx].ty = (my as f64 - GRAB_DY).max(34.0).min(max_y);
            SNAP_PREVIEW = snap_target(mx, my).map(|(r, _)| (r.0 as u32, r.1 as u32, r.2, r.3));
        }

        // Keep maximized windows filling the (possibly just-resized live)
        // workspace -- also how a live resolution change reflows them.
        {
            let fw = framebuffer::width();
            let top = TITLEBAR_MARGIN_TOP;
            let wsh = dock_y().saturating_sub(top).saturating_sub(12).max(WIN_MIN_H);
            for w in windows().iter_mut() {
                if w.used && w.maximized {
                    w.px = 0.0;
                    w.py = top as f64;
                    w.tx = 0.0;
                    w.ty = top as f64;
                    w.w = fw;
                    w.h = wsh;
                    w.scale_w = 1.0;
                    w.scale_h = 1.0;
                }
            }
        }

        // Spring advance, shared dt. Clamped like wm_liquid, but ALSO
        // sub-stepped in <=8ms slices: explicit-Euler with this stiffness
        // is numerically unstable at dt=50 (k*dt^2 ~ 70 -- observed under
        // QEMU TCG, whose frame times routinely hit the clamp, as windows
        // overshooting clean off the top of the screen).
        let now = timer::now_ms();
        let dt = if LAST_STEP_MS == 0 { 16 } else { now.saturating_sub(LAST_STEP_MS).min(50) };
        LAST_STEP_MS = now;
        let mut remaining = dt as f64;
        while remaining > 0.0 {
            let step = remaining.min(4.0);
            remaining -= step;
            for w in windows().iter_mut() {
                if !w.used {
                    continue;
                }
                // Semi-implicit Euler with IMPLICIT damping: the explicit
                // form multiplies velocity by (1 - damping*step), which
                // goes sign-alternating once damping*step > 1 (0.22*8 =
                // 1.76 here) -- observed as windows that never settle,
                // drifting to a different rest spot every run. Dividing by
                // (1 + damping*step) decays monotonically at ANY step
                // size; the spring term stays explicit so the liquid
                // overshoot the WM is named for survives.
                let k = spring_k();
                w.vx = (w.vx + (w.tx - w.px) * k * step) / (1.0 + DAMPING * step);
                w.vy = (w.vy + (w.ty - w.py) * k * step) / (1.0 + DAMPING * step);
                w.px += w.vx * step;
                w.py += w.vy * step;
                // Hard floor below the top bar: overshoot may squash
                // against it, but a titlebar must never become unreachable.
                if w.py < 34.0 {
                    w.py = 34.0;
                    if w.vy < 0.0 {
                        w.vy = 0.0;
                    }
                }
                let sx = (w.vx.abs() * STRETCH_SENSITIVITY).min(MAX_STRETCH);
                let sy = (w.vy.abs() * STRETCH_SENSITIVITY).min(MAX_STRETCH);
                w.scale_w = 1.0 + sx - sy * 0.5;
                w.scale_h = 1.0 + sy - sx * 0.5;
                // Soap-film bend: the body trails the horizontal velocity.
                // It rides the same spring (vx already oscillates as the
                // window settles), so it wobbles then relaxes to flat -- no
                // separate oscillator needed. Capped so it can't invert.
                let target_bend = (w.vx * 0.9).clamp(-(w.w as f64) * 0.22, w.w as f64 * 0.22);
                w.bend += (target_bend - w.bend) * (step / 40.0).min(1.0);
            }
        }
        // Dissolve advances per FRAME, not per kernel-ms: the TSC runs
        // tens-of-times fast under TCG, so a ms-based ramp would finish in
        // a couple of frames (invisible). ~1/26 per frame => ~26 frames of
        // sublimation, enjoyable to watch and catchable in a screendump.
        for w in windows().iter_mut() {
            if w.used && w.closing {
                w.dissolve += 0.038;
            }
        }
        // Finalize any window whose dissolve completed (outside the
        // borrow above).
        for i in 0..MAX_WINDOWS {
            if windows()[i].used && windows()[i].closing && windows()[i].dissolve >= 1.0 {
                finalize_closed(i);
            }
        }
    }
}

// -- Slot getters for the .ling draw loop (z position, back-to-front) ------

pub fn slot_count() -> usize {
    unsafe { Z_LEN }
}

fn slot_window(slot: usize) -> Option<&'static Window> {
    unsafe {
        if slot >= Z_LEN {
            return None;
        }
        let idx = Z[slot];
        let w = &windows()[idx];
        if w.used && !w.minimized {
            Some(w)
        } else {
            None
        }
    }
}

pub fn slot_x(slot: usize) -> i64 {
    slot_window(slot).map(|w| deformed_rect(w).0).unwrap_or(0)
}
pub fn slot_y(slot: usize) -> i64 {
    slot_window(slot).map(|w| deformed_rect(w).1).unwrap_or(0)
}
pub fn slot_w(slot: usize) -> u32 {
    slot_window(slot).map(|w| deformed_rect(w).2).unwrap_or(0)
}
pub fn slot_h(slot: usize) -> u32 {
    slot_window(slot).map(|w| deformed_rect(w).3).unwrap_or(0)
}
pub fn slot_kind(slot: usize) -> u8 {
    slot_window(slot).map(|w| w.kind).unwrap_or(255)
}
pub fn slot_title(slot: usize) -> &'static str {
    slot_window(slot).map(|w| kind_title(w.kind)).unwrap_or("")
}
pub fn slot_focused(slot: usize) -> bool {
    unsafe { Z_LEN > 0 && slot == Z_LEN - 1 && slot_window(slot).is_some() }
}

// -- Keyboard routing ------------------------------------------------------

/// Route one key to the focused window. Arrow keys arrive as the keyboard
/// driver's DC1/DC2/etc. control bytes, same encoding the pickers use
/// (0x11 up, 0x12 down, 0x13 left, 0x14 right per `drivers/keyboard.rs`).
pub fn key(k: u8) {
    // The Super key is the global Applications-menu launcher (the keyboard
    // path the mouse-only dock/menu never had): toggles the menu open/closed.
    if k == crate::drivers::keyboard::SUPER_KEY {
        if unsafe { MENU_OPEN } {
            unsafe { MENU_OPEN = false };
        } else {
            menu_open_kbd();
        }
        return;
    }
    // While open, the menu captures navigation keys ahead of everything else.
    if unsafe { MENU_OPEN } {
        match k {
            0x11 => menu_move(-1), // up
            0x12 => menu_move(1),  // down
            10 => {
                // Enter: activate the selected row, then close.
                let sel = unsafe { MENU_SEL };
                unsafe { MENU_OPEN = false };
                if let Some(kind) = menu_nth_action(sel) {
                    menu_activate(kind);
                }
            },
            0x1B => unsafe { MENU_OPEN = false }, // Esc closes
            _ => {},
        }
        return;
    }
    // An open tray popover captures keys ahead of the focused window.
    if tray_key(k) {
        return;
    }
    let focused_kind = unsafe {
        if Z_LEN == 0 {
            return;
        }
        windows()[Z[Z_LEN - 1]].kind
    };
    match focused_kind {
        KIND_SETTINGS => settings_key(k),
        KIND_FILES => files_key(k),
        KIND_WEB => web_key(k),
        KIND_HORIZON => horizon_key(k),
        KIND_MESSENGER => messenger::key(k),
        KIND_EDIT => editor::key(k, crate::drivers::keyboard::shift_down()),
        KIND_GALLERY => gallery::key(k),
        KIND_TERM => terminal::key(k),
        KIND_MEDIA => media::key(k),
        KIND_DAW => crate::drivers::daw::key(k),
        KIND_PKG => pkgman::key(k),
        KIND_WALLPICK => wallpick_key(k),
        _ => {},
    }
}

/// Present-rate ("Hz") presets for the Display refresh row. 0 == uncapped.
/// In a VM this caps the desktop's back-buffer present rate, not the host's
/// real refresh (honest -- see `framebuffer::set_present_hz`).
const HZ_PRESETS: [u32; 4] = [30, 60, 90, 0];

fn hz_index() -> usize {
    let cur = framebuffer::present_hz();
    HZ_PRESETS.iter().position(|&h| h == cur).unwrap_or(1)
}
fn set_hz_index(i: usize) {
    framebuffer::set_present_hz(HZ_PRESETS[i % HZ_PRESETS.len()]);
}

/// Keep the selected settings row inside the visible scroll window.
fn settings_scroll_to_cursor() {
    unsafe {
        if SETTINGS_CURSOR < SETTINGS_SCROLL {
            SETTINGS_SCROLL = SETTINGS_CURSOR;
        } else if SETTINGS_CURSOR >= SETTINGS_SCROLL + SETTINGS_VISIBLE {
            SETTINGS_SCROLL = SETTINGS_CURSOR + 1 - SETTINGS_VISIBLE;
        }
    }
}

fn settings_key(k: u8) {
    unsafe {
        // Tab switches between the General and Network settings tabs.
        if k == 0x09 {
            SETTINGS_TAB = (SETTINGS_TAB + 1) % SETTINGS_TAB_COUNT;
            SETTINGS_CURSOR = 0;
            SETTINGS_SCROLL = 0;
            return;
        }
        let rows = settings_tab_rows(SETTINGS_TAB);
        match k {
            0x11 => {
                SETTINGS_CURSOR = SETTINGS_CURSOR.saturating_sub(1);
                settings_scroll_to_cursor();
            },
            0x12 => {
                SETTINGS_CURSOR = (SETTINGS_CURSOR + 1).min(rows.saturating_sub(1));
                settings_scroll_to_cursor();
            },
            10 => {
                // Enter commits the current row. On General: Display switches
                // resolution live; Wallpaper opens the image browse picker.
                if SETTINGS_TAB == SETTINGS_TAB_GENERAL && SETTINGS_CURSOR == 3 {
                    display::apply(display::selected());
                } else if SETTINGS_TAB == SETTINGS_TAB_GENERAL && SETTINGS_CURSOR == 2 {
                    open_wallpaper_picker();
                    return;
                }
                settings_save();
            },
            0x13 | 0x14 => {
                let dir: i32 = if k == 0x13 { -1 } else { 1 };
                match SETTINGS_TAB {
                    SETTINGS_TAB_NETWORK => settings_network_adjust(SETTINGS_CURSOR, dir),
                    SETTINGS_TAB_AUDIO => settings_audio_adjust(SETTINGS_CURSOR, dir),
                    _ => settings_general_adjust(SETTINGS_CURSOR, dir),
                }
            },
            _ => {},
        }
    }
}

fn settings_general_adjust(row: usize, dir: i32) {
    match row {
        0 => {
            let n = theme::count() as i32;
            let cur = theme::current() as i32;
            theme::set(((cur + dir + n) % n) as usize);
        },
        1 => {
            let n = mixer::sound_theme_count() as i32;
            let cur = mixer::sound_theme() as i32;
            mixer::set_sound_theme(((cur + dir + n) % n) as usize);
            mixer::jingle(mixer::EVENT_LOGIN); // audition it
        },
        2 => {
            let n = WALL_MODES as i32;
            let cur = unsafe { WALLPAPER } as i32;
            set_wallpaper(((cur + dir + n) % n) as u32);
        },
        3 => {
            // Display: cycle the target mode (applied on Enter/Apply/OK).
            let n = display::mode_count() as i32;
            let cur = display::selected() as i32;
            display::set_selected(((cur + dir + n) % n) as usize);
        },
        4 => spring_adjust(dir * 2),
        5 => {
            let n = kbdlayout::count() as i32;
            let cur = kbdlayout::current() as i32;
            kbdlayout::set_current(((cur + dir + n) % n) as usize);
        },
        6 => {
            let n = locale::count() as i32;
            let cur = locale::selected().unwrap_or(0) as i32;
            locale::select(((cur + dir + n) % n) as usize);
        },
        7 => unsafe { CLOCK_24H = !CLOCK_24H },
        8 => {
            // DPI: UI text scale preset (live).
            let n = ui_scale::DPI_PRESETS.len() as i32;
            let cur = ui_scale::dpi_index() as i32;
            ui_scale::set_dpi_index(((cur + dir + n) % n) as usize);
            font8x8::set_scale(ui_scale::dpi_font_scale());
        },
        9 => {
            // Refresh (Hz): desktop present-rate cap (live).
            let n = HZ_PRESETS.len() as i32;
            let cur = hz_index() as i32;
            set_hz_index(((cur + dir + n) % n) as usize);
        },
        _ => {
            // Wallpaper scale mode (cover/fit/stretch/tile/center), applied live.
            let n = wallpaper::SCALE_MODES as i32;
            let cur = wallpaper::scale_mode() as i32;
            wallpaper::set_scale_mode(((cur + dir + n) % n) as u8);
        },
    }
}

fn settings_network_adjust(row: usize, dir: i32) {
    match row {
        0 => {
            // IP mode: DHCP <-> static (MAC-derived), applied immediately.
            let mode = unsafe {
                NET_IP_MODE = (NET_IP_MODE + 1) % 2;
                NET_IP_MODE
            };
            if mode == 0 {
                netstack::dhcp_configure();
            } else {
                netstack::apply_static_ip_from_mac();
            }
        },
        1 => {
            // Nameserver preset (Cloudflare / Quad9 / AdGuard / Ling / LAN / NAT).
            let n = DNS_PRESETS.len() as i32;
            unsafe { DNS_PRESET = ((DNS_PRESET as i32 + dir + n) % n) as usize };
            apply_dns_preset();
        },
        _ => {
            // SSH server at boot (on/off) -- persisted to /services.
            crate::services::set_ssh(!crate::services::ssh_enabled());
        },
    }
}

fn settings_audio_adjust(row: usize, dir: i32) {
    match row {
        0 => {
            // Master volume, +/- 10%.
            let cur = mixer::master_volume() as i32;
            mixer::set_master_volume((cur + dir * 10).clamp(0, 100) as u32);
        },
        1 => mixer::set_mono(!mixer::mono()), // Channels: Stereo <-> Mono
        _ => {
            // Test speakers: left arrow -> left ding, right arrow -> right ding.
            mixer::test_tone(dir);
        },
    }
}

fn fm_dir() -> &'static str {
    unsafe {
        core::str::from_utf8(&(&*&raw const FM_DIR)[..FM_DIR_LEN]).unwrap_or("")
    }
}

fn fm_home(buf: &mut [u8; FM_PATH_MAX]) -> &str {
    let user = lingfs::current_user();
    let mut n = 0;
    for &b in b"home/".iter().chain(user.as_bytes()) {
        if n < buf.len() {
            buf[n] = b;
            n += 1;
        }
    }
    core::str::from_utf8(&buf[..n]).unwrap_or("home")
}

fn fm_set_dir(dir: &str) {
    unsafe {
        let n = dir.len().min(FM_PATH_MAX);
        (&mut *&raw mut FM_DIR)[..n].copy_from_slice(&dir.as_bytes()[..n]);
        FM_DIR_LEN = n;
        FM_CURSOR = 0;
        FM_SCROLL = 0;
        FM_MSG = "";
    }
}

/// Files opens in the signed-in user's home folder (when it exists).
fn fm_go_home() {
    let mut hb = [0u8; FM_PATH_MAX];
    let home = fm_home(&mut hb);
    if lingfs::path_kind(home) == Some(true) {
        fm_set_dir(home);
    }
}

/// Rebuild FM_ITEMS if the folder or the filesystem changed.
fn fm_refresh() {
    let key = (lingfs::commit_count(), unsafe { FM_DIR_LEN }, {
        let d = fm_dir().as_bytes();
        d.iter().fold(1469598103934665603u64, |h, &b| (h ^ b as u64).wrapping_mul(1099511628211))
    });
    unsafe {
        if FM_ITEMS_KEY == key {
            return;
        }
        FM_ITEMS_KEY = key;
    }
    let mut raw: alloc::vec::Vec<(alloc::string::String, bool)> = alloc::vec::Vec::new();
    let mut buf = [0u8; FM_NAME_MAX];
    let mut i = 0;
    while i < 64 {
        let Some((len, is_dir)) = lingfs::list_entry(fm_dir(), i, &mut buf) else { break };
        raw.push((alloc::string::String::from(core::str::from_utf8(&buf[..len]).unwrap_or("?")), is_dir));
        i += 1;
    }
    // Folders first; the standard home folders in their fixed order; then by name.
    let rank = |(n, d): &(alloc::string::String, bool)| -> (u8, usize, alloc::string::String) {
        let hf = crate::fs::users::HOME_FOLDERS.iter().position(|f| *f == n.as_str());
        match (d, hf) {
            (true, Some(k)) => (0, k, alloc::string::String::new()),
            (true, None) => (1, 0, n.to_ascii_lowercase()),
            _ => (2, 0, n.to_ascii_lowercase()),
        }
    };
    raw.sort_by(|a, b| rank(a).cmp(&rank(b)));
    unsafe { *(&raw mut FM_ITEMS) = raw };
}

fn fm_entry_count() -> usize {
    fm_refresh();
    unsafe { (*(&raw const FM_ITEMS)).len() }
}

/// Entry `i` of the current folder in display order: copies the name into
/// `out`, returns (len, is_dir).
fn fm_entry(i: usize, out: &mut [u8]) -> Option<(usize, bool)> {
    fm_refresh();
    let items = unsafe { &*(&raw const FM_ITEMS) };
    let (name, is_dir) = items.get(i)?;
    let n = name.len().min(out.len());
    out[..n].copy_from_slice(&name.as_bytes()[..n]);
    Some((n, *is_dir))
}

/// "dir/name" (or "name" at the root) into `out`.
fn fm_join<'a>(out: &'a mut [u8; FM_PATH_MAX], name: &[u8]) -> &'a str {
    let dir = fm_dir().as_bytes();
    let mut n = 0;
    let mut put = |b: u8, n: &mut usize| {
        if *n < FM_PATH_MAX {
            out[*n] = b;
            *n += 1;
        }
    };
    for &b in dir {
        put(b, &mut n);
    }
    if !dir.is_empty() {
        put(b'/', &mut n);
    }
    for &b in name {
        put(b, &mut n);
    }
    core::str::from_utf8(&out[..n]).unwrap_or("")
}

/// What kind of file a name is, for its icon and how it opens.
#[derive(Clone, Copy, PartialEq)]
enum FmType {
    Image,
    Svg,
    Audio,
    Video,
    Ling,
    Text,
    Model,
    Other,
}

fn fm_type(name: &[u8]) -> FmType {
    let ext = |e: &[u8]| name.len() > e.len() && name[name.len() - e.len()..].eq_ignore_ascii_case(e);
    if ext(b".bmp") || ext(b".png") {
        FmType::Image
    } else if ext(b".svg") {
        FmType::Svg
    } else if ext(b".wav") || ext(b".mp3") || ext(b".ogg") || ext(b".flac") || ext(b".mid") {
        FmType::Audio
    } else if ext(b".mp4") || ext(b".mkv") || ext(b".webm") || ext(b".avi") || ext(b".mov") || ext(b".gif") {
        FmType::Video
    } else if ext(b".ling") {
        FmType::Ling
    } else if ext(b".obj") || ext(b".stl") || ext(b".glb") || ext(b".gltf") || ext(b".ply") || ext(b".blend") {
        FmType::Model
    } else if is_editable(name) || ext(b".json") || ext(b".csv") {
        FmType::Text
    } else {
        FmType::Other
    }
}

// 8x8 emblems (bit 7 = left pixel) drawn on folder and file icons.
const EMB_DOC: [u8; 8] = [0x00, 0x7E, 0x00, 0x7E, 0x00, 0x78, 0x00, 0x00];
const EMB_PHOTO: [u8; 8] = [0x06, 0x06, 0x00, 0x20, 0x72, 0xFF, 0xFF, 0x00];
const EMB_MUSIC: [u8; 8] = [0x1E, 0x12, 0x12, 0x12, 0x72, 0xF6, 0x66, 0x00];
const EMB_VIDEO: [u8; 8] = [0x40, 0x60, 0x70, 0x78, 0x70, 0x60, 0x40, 0x00];
const EMB_GRAPHICS: [u8; 8] = [0x38, 0x44, 0x44, 0x38, 0x10, 0x38, 0x7C, 0x00];
const EMB_MODEL: [u8; 8] = [0x3E, 0x46, 0xFA, 0x8A, 0x8C, 0x88, 0xF8, 0x00];
const EMB_PROGRAM: [u8; 8] = [0xFF, 0x81, 0xA1, 0x91, 0xA1, 0x81, 0xFF, 0x00];
const EMB_SCRIPT: [u8; 8] = [0x24, 0x42, 0x81, 0x00, 0x81, 0x42, 0x24, 0x00];

fn draw_emblem(x: u32, y: u32, rows: &[u8; 8], color: u32) {
    for (r, bits) in rows.iter().enumerate() {
        for c in 0..8u32 {
            if bits & (0x80 >> c) != 0 {
                framebuffer::back_set_pixel(x + c, y + r as u32, color);
            }
        }
    }
}

/// A folder (tab + body, coloured by kind, emblem for the home folders) or a
/// page-shaped file icon coloured and marked by file type. 18x14 at (x, y).
fn draw_fm_icon(x: u32, y: u32, name: &[u8], is_dir: bool) {
    let ink = 0x1A1423;
    if is_dir {
        let (color, emb): (u32, Option<&[u8; 8]>) = match name {
            b"Documents" => (0x4A8FE0, Some(&EMB_DOC)),
            b"Photos" => (0x5AC06A, Some(&EMB_PHOTO)),
            b"Music" => (0xE0A44A, Some(&EMB_MUSIC)),
            b"Videos" => (0xE0605A, Some(&EMB_VIDEO)),
            b"Graphics" => (0xB06AE0, Some(&EMB_GRAPHICS)),
            b"3D Models" => (0x40C0C0, Some(&EMB_MODEL)),
            b"Programs" => (0x8A9BB8, Some(&EMB_PROGRAM)),
            b"Scripts" => (0xF0D050, Some(&EMB_SCRIPT)),
            _ => (theme::color(theme::SLOT_ACCENT), None),
        };
        framebuffer::back_fill_rounded_rect(x, y, 8, 4, 1, color);
        framebuffer::back_fill_rounded_rect(x, y + 2, 18, 12, 2, color);
        if let Some(e) = emb {
            draw_emblem(x + 5, y + 4, e, ink);
        }
    } else {
        let t = fm_type(name);
        let (color, emb): (u32, Option<&[u8; 8]>) = match t {
            FmType::Image => (0x5AC06A, Some(&EMB_PHOTO)),
            FmType::Svg => (0xB06AE0, Some(&EMB_GRAPHICS)),
            FmType::Audio => (0xE0A44A, Some(&EMB_MUSIC)),
            FmType::Video => (0xE0605A, Some(&EMB_VIDEO)),
            FmType::Ling => (0xF0D050, Some(&EMB_SCRIPT)),
            FmType::Model => (0x40C0C0, Some(&EMB_MODEL)),
            FmType::Text => (0xC8C0D8, Some(&EMB_DOC)),
            FmType::Other => (theme::color(theme::SLOT_DIM), None),
        };
        framebuffer::back_fill_rounded_rect(x + 2, y, 14, 14, 2, color);
        if let Some(e) = emb {
            draw_emblem(x + 5, y + 3, e, ink);
        }
    }
}

/// Extensions the Editor opens (everything else is a binary peek). Also
/// treats extensionless names as text -- most lingfs synthetic files
/// (hostname, language) are plain text.
fn is_editable(name: &[u8]) -> bool {
    let ext = |suffix: &[u8]| name.len() >= suffix.len() && &name[name.len() - suffix.len()..] == suffix;
    ext(b".ling")
        || ext(b".txt")
        || ext(b".md")
        || ext(b".cfg")
        || ext(b".toml")
        || ext(b".sh")
        || !name.contains(&b'.')
}

/// Open the highlighted entry: folders open in place (any depth); files go to
/// the app for their type.
fn fm_open_selected() {
    let mut name = [0u8; FM_NAME_MAX];
    let Some((len, is_dir)) = (unsafe { fm_entry(FM_CURSOR, &mut name) }) else { return };
    let mut pb = [0u8; FM_PATH_MAX];
    let full = fm_join(&mut pb, &name[..len]);
    if is_dir {
        let n = full.len();
        let owned: [u8; FM_PATH_MAX] = pb;
        fm_set_dir(core::str::from_utf8(&owned[..n]).unwrap_or(""));
        return;
    }
    unsafe {
        let vn = &mut *&raw mut FM_VIEW_NAME;
        vn[..full.len()].copy_from_slice(full.as_bytes());
        FM_VIEW_NAME_LEN = full.len();
        FM_MSG = "";
    }
    match fm_type(&name[..len]) {
        // Photos (BMP/PNG) and SVG drawings open in the Gallery.
        FmType::Image | FmType::Svg => {
            if gallery::open(full) {
                open(KIND_GALLERY);
            } else {
                unsafe { FM_MSG = "couldn't decode that picture" };
            }
        },
        FmType::Audio => {
            if media::open(full) {
                open(KIND_MEDIA);
            } else {
                unsafe { FM_MSG = "Media Player plays WAV files (other audio formats: not yet)" };
            }
        },
        FmType::Video => unsafe { FM_MSG = "video playback isn't available yet (no video codec)" },
        // .ling scripts and graphics programs run (e = edit instead).
        FmType::Ling => {
            let mut cmd = [0u8; FM_PATH_MAX + 9];
            cmd[..9].copy_from_slice(b"ling run ");
            cmd[9..9 + full.len()].copy_from_slice(full.as_bytes());
            open(KIND_TERM);
            terminal::run_command(core::str::from_utf8(&cmd[..9 + full.len()]).unwrap_or(""));
        },
        FmType::Text => {
            editor::load(full);
            open(KIND_EDIT);
        },
        FmType::Model | FmType::Other => unsafe { FM_VIEWING = true },
    }
}

fn files_key(k: u8) {
    use crate::drivers::{clipboard, keyboard as kb};
    unsafe {
        // Ctrl+C copies the highlighted entry's path to the clipboard, so
        // it can be pasted into the editor, terminal, or browser URL bar.
        if k == kb::CTRL_C {
            let mut nm = [0u8; FM_NAME_MAX];
            if let Some((len, _)) = fm_entry(FM_CURSOR, &mut nm) {
                let mut pb = [0u8; FM_PATH_MAX];
                clipboard::set(fm_join(&mut pb, &nm[..len]).as_bytes());
            }
            return;
        }
        if FM_VIEWING {
            // Any of backspace/left/enter leaves the file view.
            if k == 0x08 || k == 0x13 || k == 10 {
                FM_VIEWING = false;
            }
            return;
        }
        let count = fm_entry_count();
        match k {
            0x11 => FM_CURSOR = FM_CURSOR.saturating_sub(1),
            0x12 => {
                if count > 0 {
                    FM_CURSOR = (FM_CURSOR + 1).min(count - 1);
                }
            },
            0x08 | 0x13 => {
                // Up one folder (to the root from a top-level folder).
                let dir = fm_dir();
                let parent = match dir.rfind('/') {
                    Some(i) => &dir[..i],
                    None => "",
                };
                let mut tmp = [0u8; FM_PATH_MAX];
                let n = parent.len();
                tmp[..n].copy_from_slice(parent.as_bytes());
                fm_set_dir(core::str::from_utf8(&tmp[..n]).unwrap_or(""));
            },
            b'h' | b'H' => fm_go_home(),
            10 => fm_open_selected(),
            b'e' | b'E' => {
                // Edit a script/text file instead of running it.
                let mut name = [0u8; FM_NAME_MAX];
                if let Some((len, false)) = fm_entry(FM_CURSOR, &mut name) {
                    if matches!(fm_type(&name[..len]), FmType::Ling | FmType::Text) {
                        let mut pb = [0u8; FM_PATH_MAX];
                        editor::load(fm_join(&mut pb, &name[..len]));
                        open(KIND_EDIT);
                    }
                }
            },
            0x77 | 0x57 => {
                // 'w' / 'W': set the highlighted .bmp as the desktop wallpaper.
                let mut name = [0u8; FM_NAME_MAX];
                if let Some((len, is_dir)) = fm_entry(FM_CURSOR, &mut name) {
                    if !is_dir && len >= 4 && name[len - 4..len].eq_ignore_ascii_case(b".bmp") {
                        let mut pb = [0u8; FM_PATH_MAX];
                        if set_wallpaper_image(fm_join(&mut pb, &name[..len])) {
                            settings_save();
                        }
                    }
                }
            },
            _ => {},
        }
        if FM_CURSOR < FM_SCROLL {
            FM_SCROLL = FM_CURSOR;
        }
        if FM_CURSOR >= FM_SCROLL + FM_VISIBLE_ROWS {
            FM_SCROLL = FM_CURSOR - FM_VISIBLE_ROWS + 1;
        }
    }
}

/// Mouse in the Files window: click selects a row, double-click (or a click
/// on the selected row within 450ms) opens it; the path header goes up.
fn files_click(wx: i64, wy: i64, dw: u32, mx: i64, my: i64) -> bool {
    let x = (wx.max(0) as u32) + 16;
    let y = (wy.max(0) as u32) + TITLEBAR_H + 14;
    unsafe {
        if FM_VIEWING {
            FM_VIEWING = false;
            return true;
        }
        if pt_in(mx, my, (x, y.saturating_sub(4), dw.saturating_sub(32), 16)) {
            files_key(0x08);
            return true;
        }
        let row_w = dw.saturating_sub(32).max(40);
        let count = fm_entry_count();
        for vis in 0..FM_VISIBLE_ROWS {
            let i = FM_SCROLL + vis;
            if i >= count {
                break;
            }
            let ry = y + 22 + vis as u32 * 24;
            if pt_in(mx, my, (x, ry.saturating_sub(5), row_w, 20)) {
                let now = crate::arch::timer::now_ms();
                let double = FM_CLICK.1 == i && now.saturating_sub(FM_CLICK.0) < 450;
                FM_CURSOR = i;
                FM_CLICK = (now, i);
                if double {
                    FM_CLICK = (0, usize::MAX);
                    fm_open_selected();
                }
                return true;
            }
        }
    }
    false
}

// -- Top-bar tray: volume + network widgets --------------------------------
// MATE-panel-shaped: two icons at the top bar's right end (left of the
// clock); clicking the speaker opens a volume popover with a master row
// plus one row per app stream (the mixer's per-app volumes), adjusted
// with up/down + left/right while open. The net icon just reports the
// real e1000 state -- ethernet only, because that's the NIC we have.

static mut TRAY_OPEN: bool = false;
static mut TRAY_CURSOR: usize = 0; // 0 = master, 1.. = mixer streams

fn tray_vol_x() -> i64 {
    framebuffer::width() as i64 - 140
}

fn tray_net_x() -> i64 {
    framebuffer::width() as i64 - 110
}

fn tray_hit(mx: i64, my: i64) -> bool {
    if my >= 30 {
        return false;
    }
    if mx >= tray_vol_x() && mx < tray_vol_x() + 24 {
        unsafe {
            TRAY_OPEN = !TRAY_OPEN;
            TRAY_CURSOR = 0;
        }
        mixer::jingle(mixer::EVENT_CLICK);
        return true;
    }
    // The net icon has no popover yet; swallowing the click keeps it from
    // falling through to a window underneath.
    mx >= tray_net_x() && mx < tray_net_x() + 24
}

pub fn tray_open() -> bool {
    unsafe { TRAY_OPEN }
}

fn tray_key(k: u8) -> bool {
    unsafe {
        if !TRAY_OPEN {
            return false;
        }
        let rows = 1 + mixer::stream_count();
        match k {
            0x11 => TRAY_CURSOR = TRAY_CURSOR.saturating_sub(1),
            0x12 => TRAY_CURSOR = (TRAY_CURSOR + 1).min(rows - 1),
            0x13 | 0x14 => {
                let delta: i64 = if k == 0x13 { -10 } else { 10 };
                if TRAY_CURSOR == 0 {
                    let v = (mixer::master_volume() as i64 + delta).clamp(0, 100);
                    mixer::set_master_volume(v as u32);
                } else {
                    let s = TRAY_CURSOR - 1;
                    let v = (mixer::stream_volume(s) as i64 + delta).clamp(0, 100);
                    mixer::set_stream_volume(s, v as u32);
                }
                mixer::jingle(mixer::EVENT_CLICK);
            },
            0x1B | 10 => TRAY_OPEN = false, // Esc or Enter closes
            _ => {},
        }
        true
    }
}

/// Draw the tray icons and (if open) the volume popover. `net_up` is the
/// real e1000 init result the desktop got at boot.
pub fn draw_tray(net_up: bool) {
    let panel = theme::color(theme::SLOT_PANEL);
    let accent = theme::color(theme::SLOT_ACCENT);
    let dim = theme::color(theme::SLOT_DIM);
    let text = theme::color(theme::SLOT_TEXT);

    // Speaker: box + cone triangle-ish (two rects) + a sound arc dot.
    let vx = tray_vol_x() as u32;
    framebuffer::back_fill_rect(vx, 11, 6, 8, if mixer::master_volume() > 0 { text } else { dim });
    framebuffer::back_fill_rect(vx + 6, 8, 5, 14, if mixer::master_volume() > 0 { text } else { dim });
    if mixer::master_volume() > 0 {
        framebuffer::back_fill_circle(vx + 16, 15, 2, accent);
    }

    // Net: rounded plug + stem; accent when the NIC is up, dim otherwise.
    let nx = tray_net_x() as u32;
    let net_color = if net_up { accent } else { dim };
    framebuffer::back_fill_rounded_rect(nx, 9, 14, 10, 3, net_color);
    framebuffer::back_fill_rect(nx + 6, 19, 2, 5, net_color);

    if !unsafe { TRAY_OPEN } {
        return;
    }
    // Popover: rounded card under the speaker with master + stream rows.
    let rows = 1 + mixer::stream_count();
    let row_h = 26u32;
    let pw = 240u32;
    let ph = rows as u32 * row_h + 20;
    let px = (framebuffer::width()).saturating_sub(pw + 16);
    let py = 34u32;
    framebuffer::back_blend_rounded_rect(px + 4, py + 5, pw, ph, 10, theme::color(theme::SLOT_SHADOW), 80);
    framebuffer::back_fill_rounded_rect(px, py, pw, ph, 10, theme::color(theme::SLOT_PANEL_BORDER));
    framebuffer::back_fill_rounded_rect(px + 1, py + 1, pw - 2, ph - 2, 9, panel);
    for r in 0..rows {
        let ry = py + 10 + r as u32 * row_h;
        let selected = unsafe { TRAY_CURSOR == r };
        if selected {
            framebuffer::back_fill_rounded_rect(px + 6, ry.saturating_sub(4), pw - 12, row_h - 4, 5, accent);
            framebuffer::back_fill_rounded_rect(px + 8, ry.saturating_sub(2), pw - 16, row_h - 8, 4, panel);
        }
        let (name, vol): (&str, u32) = if r == 0 {
            ("master", mixer::master_volume())
        } else {
            (mixer::stream_name(r - 1), mixer::stream_volume(r - 1))
        };
        font8x8::draw_str(px + 14, ry, name.as_bytes(), if r == 0 { text } else { dim }, panel);
        // Volume bar: track + fill, 100px wide.
        let bx = px + 110;
        framebuffer::back_fill_rounded_rect(bx, ry + 2, 100, 6, 3, theme::color(theme::SLOT_DOT_DIM));
        if vol > 0 {
            framebuffer::back_fill_rounded_rect(bx, ry + 2, vol, 6, 3, accent);
        }
        // Exclusive-mode marker on the holding stream.
        if r > 0 && mixer::exclusive_holder() == (r - 1) as i32 {
            font8x8::draw_str(px + pw - 24, ry, b"!", theme::color(theme::SLOT_ERROR), panel);
        }
    }
}

// -- Clock -----------------------------------------------------------------

static mut CLOCK_BUF: [u8; 16] = [0; 16];

/// "HH:MM" (or "hh:MM am/pm") in the selected locale's UTC offset --
/// the real CMOS clock through the real locale table, not a placeholder.
pub fn clock_str() -> &'static str {
    let ts = rtc::unix_timestamp();
    let offset_min = locale::selected()
        .and_then(locale::get)
        .map(|l| l.utc_offset_min)
        .unwrap_or(0) as i64;
    let local = ts + offset_min * 60;
    let mins_of_day = ((local % 86400 + 86400) % 86400) / 60;
    let (h24, m) = (mins_of_day / 60, mins_of_day % 60);
    unsafe {
        let buf = &mut *&raw mut CLOCK_BUF;
        let (h, suffix): (i64, &[u8]) = if CLOCK_24H {
            (h24, b"")
        } else {
            let h12 = if h24 % 12 == 0 { 12 } else { h24 % 12 };
            (h12, if h24 < 12 { b" am" } else { b" pm" })
        };
        buf[0] = b'0' + (h / 10) as u8;
        buf[1] = b'0' + (h % 10) as u8;
        buf[2] = b':';
        buf[3] = b'0' + (m / 10) as u8;
        buf[4] = b'0' + (m % 10) as u8;
        let mut len = 5;
        for &b in suffix {
            buf[len] = b;
            len += 1;
        }
        core::str::from_utf8(&buf[..len]).unwrap_or("00:00")
    }
}

/// Mix two 0xRRGGBB colors: `t_num/t_den` of `b`, the rest `a`.
fn mix_rgb(a: u32, b: u32, t_num: u32, t_den: u32) -> u32 {
    let mut out = 0u32;
    for ch in 0..3 {
        let ca = (a >> (ch * 8)) & 0xFF;
        let cb = (b >> (ch * 8)) & 0xFF;
        let m = (ca * (t_den - t_num) + cb * t_num) / t_den;
        out |= m << (ch * 8);
    }
    out
}

/// The seven ROYGBIV hues, each pre-tempered toward the active theme's
/// background so the rainbow reads as *of* the theme (LingOS dusk purple
/// under Dusk, light-washed under Daylight) instead of a paint-store
/// swatch pasted on top.
fn roygbiv_color(i: usize) -> u32 {
    const HUES: [u32; 7] = [
        0xE0453A, // red
        0xE08A3A, // orange
        0xE0D24A, // yellow
        0x4AE06A, // green
        0x3A8AE0, // blue
        0x4A4AE0, // indigo
        0x9A4AE0, // violet
    ];
    mix_rgb(HUES[i % 7], theme::color(theme::SLOT_BG), 55, 100)
}

/// Wallpaper renderer, mode from the Settings row: theme gradient (the
/// default), the LingOS ROYGBIV diagonal (seven theme-tempered stripes
/// with a blended edge band between neighbors), or solid. Per-row span
/// fills only -- no per-pixel work, cheap enough to redraw every frame
/// even under TCG.
pub fn draw_wallpaper() {
    let h = framebuffer::height();
    let w = framebuffer::width();
    if h == 0 {
        return;
    }
    // One-time init: bake in the built-in "Ling Country" wallpaper (embedded,
    // pre-rasterized from assets/ling_country.svg) and make it the default
    // background. The Settings > Wallpaper row still cycles to the gradient/etc.
    unsafe {
        if !WALLPAPER_INIT {
            WALLPAPER_INIT = true;
            // A user-picked wallpaper path persisted to "/wallpaper" wins;
            // otherwise fall back to the built-in embedded "Ling Country".
            let mut pb = [0u8; 128];
            let loaded_user = match lingfs::read_file_all("/wallpaper", &mut pb) {
                Ok(Some(n)) if n > 0 => {
                    core::str::from_utf8(&pb[..n]).ok().map(|p| p.trim()).filter(|p| !p.is_empty()).map(|p| wallpaper::load(p)).unwrap_or(false)
                },
                _ => false,
            };
            if loaded_user || wallpaper::load_embedded() {
                WALLPAPER = WALL_IMAGE;
            }
        }
    }
    match unsafe { WALLPAPER } {
        WALL_IMAGE => {
            if wallpaper::loaded() {
                wallpaper::draw();
            } else {
                framebuffer::back_clear(theme::color(theme::SLOT_BG));
            }
        },
        WALL_SOLID => framebuffer::back_clear(theme::color(theme::SLOT_BG)),
        WALL_ROYGBIV => {
            // Diagonal stripes: each row shifts the stripe origin left by
            // half a pixel per row. Stripe width spans the diagonal extent
            // so all seven hues are on-screen at once; each stripe's last
            // ~25% blends into the next hue for a soft edge.
            let diag = w + h / 2;
            let sw = (diag / 7).max(8);
            let blend_w = sw / 4;
            for row in 0..h {
                let offset = (row / 2) as i64;
                for i in 0..8i64 {
                    let hue = ((i % 7) + 7) as usize % 7;
                    let next = (hue + 1) % 7;
                    let x0 = i * sw as i64 - offset;
                    let solid_w = sw - blend_w;
                    // Solid body of the stripe.
                    let sx = x0.max(0);
                    let sw_clip = (x0 + solid_w as i64 - sx).max(0) as u32;
                    if sx < w as i64 && sw_clip > 0 {
                        framebuffer::back_fill_rect(sx as u32, row, sw_clip, 1, roygbiv_color(hue));
                    }
                    // Blended edge band, in 4 sub-steps toward the next hue.
                    for b in 0..4u32 {
                        let bx = x0 + solid_w as i64 + (blend_w / 4 * b) as i64;
                        let bw = (blend_w / 4).max(1);
                        if bx + (bw as i64) < 0 || bx >= w as i64 {
                            continue;
                        }
                        let c = mix_rgb(roygbiv_color(hue), roygbiv_color(next), b * 25 + 12, 100);
                        framebuffer::back_fill_rect(bx.max(0) as u32, row, bw, 1, c);
                    }
                }
            }
        },
        _ => {
            let top = theme::color(theme::SLOT_WALL_TOP);
            let bottom = theme::color(theme::SLOT_WALL_BOTTOM);
            let denom = (h - 1).max(1) as u64;
            for row in 0..h {
                let mut color = 0u32;
                for ch in 0..3 {
                    let a = ((top >> (ch * 8)) & 0xFF) as u64;
                    let b = ((bottom >> (ch * 8)) & 0xFF) as u64;
                    let mixed = (a * (h - 1 - row) as u64 + b * row as u64) / denom;
                    color |= (mixed as u32) << (ch * 8);
                }
                framebuffer::back_fill_rect(0, row, w, 1, color);
            }
        },
    }
}

// -- Content rendering -----------------------------------------------------

fn draw_row_ring(x: u32, y: u32, w: u32, h: u32, selected: bool) {
    if selected {
        framebuffer::back_fill_rounded_rect(x, y, w, h, 4, theme::color(theme::SLOT_ACCENT));
        framebuffer::back_fill_rounded_rect(
            x + 2,
            y + 2,
            w.saturating_sub(4),
            h.saturating_sub(4),
            3,
            theme::color(theme::SLOT_PANEL),
        );
    }
}

// Settings Apply/OK button rects, in absolute screen pixels, from the
// window's deformed rect. OK sits bottom-right, Apply just left of it.
fn settings_ok_rect(wx: i64, wy: i64, dw: u32, dh: u32) -> (u32, u32, u32, u32) {
    let (bw, bh) = (70u32, 28u32);
    let ox = (wx + dw as i64 - bw as i64 - 16).max(0) as u32;
    let oy = (wy + dh as i64 - bh as i64 - 14).max(0) as u32;
    (ox, oy, bw, bh)
}

fn settings_apply_rect(wx: i64, wy: i64, dw: u32, dh: u32) -> (u32, u32, u32, u32) {
    let (ox, oy, bw, bh) = settings_ok_rect(wx, wy, dw, dh);
    (ox.saturating_sub(bw + 10), oy, bw, bh)
}

/// Rect of the `tab`-th settings tab button (General=0, Network=1), at the top
/// of the content area. Matches the draw + the click hit-test.
fn settings_tab_rect(wx: i64, wy: i64, _dw: u32, tab: usize) -> (u32, u32, u32, u32) {
    let tx = (wx.max(0) as u32) + 16 + tab as u32 * 110;
    let ty = (wy.max(0) as u32) + TITLEBAR_H + 14;
    (tx, ty, 100, 26)
}

/// Draw a dotted-quad IP address at (x, y).
fn draw_ip(x: u32, y: u32, ip: [u8; 4], color: u32, bg: u32) {
    let mut b = [0u8; 16];
    let mut n = 0;
    for (k, oct) in ip.iter().enumerate() {
        if k > 0 && n < b.len() {
            b[n] = b'.';
            n += 1;
        }
        n += write_u32_into(&mut b[n..], *oct as u32);
    }
    font8x8::draw_str(x, y, &b[..n], color, bg);
}

fn pt_in(px: i64, py: i64, r: (u32, u32, u32, u32)) -> bool {
    px >= r.0 as i64 && px < (r.0 + r.2) as i64 && py >= r.1 as i64 && py < (r.1 + r.3) as i64
}

/// Handle a click inside a window's content area (below the titlebar).
/// Today only Settings has clickable content (Apply/OK). Returns true if
/// the click was consumed.
fn content_click(idx: usize, mx: i64, my: i64) -> bool {
    let w = windows()[idx];
    let (wx, wy, dw, dh) = deformed_rect(&w);
    if w.kind == KIND_FILES {
        return files_click(wx, wy, dw, mx, my);
    }
    if w.kind == KIND_SETTINGS {
        // Tab buttons switch tabs.
        for t in 0..SETTINGS_TAB_COUNT {
            if pt_in(mx, my, settings_tab_rect(wx, wy, dw, t)) {
                unsafe {
                    SETTINGS_TAB = t;
                    SETTINGS_CURSOR = 0;
                    SETTINGS_SCROLL = 0;
                }
                return true;
            }
        }
        if pt_in(mx, my, settings_ok_rect(wx, wy, dw, dh)) {
            display::apply(display::selected()); // live resolution switch
            settings_save();
            close(idx);
            return true;
        }
        if pt_in(mx, my, settings_apply_rect(wx, wy, dw, dh)) {
            display::apply(display::selected());
            settings_save();
            return true;
        }
        return false;
    }
    if w.kind == KIND_WEB {
        // The URL bar sits at the top of the content; clicking it focuses the
        // bar for editing. Clicking the page below defocuses the bar and, if
        // the click lands on a link, follows it (mouse-clickable links).
        let cx = wx.max(0) + 16;
        let bar_y = wy.max(0) + TITLEBAR_H as i64 + 14;
        let bar = (cx as u32, bar_y as u32, dw.saturating_sub(24), 22u32);
        if pt_in(mx, my, bar) {
            unsafe { WEB_EDITING = true };
            return true;
        }
        unsafe { WEB_EDITING = false };
        // draw_page's origin y = bar_y + bar_h(22) + 6; map the click into it.
        let page_y0 = bar_y + 22 + 6;
        browser::click_page(my - page_y0, unsafe { WEB_COLS });
        return true;
    }
    if w.kind == KIND_HORIZON {
        let cx = wx.max(0) + 16;
        let bar_y = wy.max(0) + TITLEBAR_H as i64 + 14;
        let bar = (cx as u32, bar_y as u32, dw.saturating_sub(24), 22u32);
        if pt_in(mx, my, bar) {
            unsafe { HZ_EDITING = true };
            return true;
        }
        unsafe { HZ_EDITING = false };
        let page_x0 = cx;
        let page_y0 = bar_y + 22 + 6;
        horizon::click_page(mx - page_x0, my - page_y0, unsafe { HZ_VIEWPORT_W });
        return true;
    }
    if w.kind == KIND_DAW {
        // the same origin draw_content_inner gives daw::draw
        let cx = (wx.max(0) as u32) + 16;
        let cy = (wy.max(0) as u32) + TITLEBAR_H + 14;
        return crate::drivers::daw::click(mx, my, cx, cy);
    }
    if w.kind == KIND_MESSENGER {
        // Geometry must track messenger::draw's SIDEBAR_W / LIST_TOP / ROW_H.
        let cx = wx.max(0) + 16;
        let cy = wy.max(0) + TITLEBAR_H as i64 + 14;
        if mx >= cx && mx < cx + 190 {
            let rel_y = my - cy - 34;
            if rel_y >= 0 {
                messenger::click_row((rel_y / 40) as usize);
            }
        }
        return true;
    }
    false
}

/// Render the interior of the window at z `slot` into the back buffer.
/// The `.ling` side has already drawn the shadow/frame/titlebar; `x..y`
/// here is the content origin (below the titlebar).
pub fn draw_content(slot: usize) {
    let Some(w) = slot_window(slot) else { return };
    let (wx, wy, dw, dh) = deformed_rect(w);
    // Scissor to the visible window body (below the titlebar) so an app's
    // content -- long text, wide tables, oversized DPI glyphs -- is cut off at
    // the window edge instead of bleeding onto the desktop or other windows.
    let x0 = wx.max(0);
    let x1 = (wx + dw as i64).max(0);
    let y0 = (wy + TITLEBAR_H as i64).max(0);
    let y1 = (wy + dh as i64).max(0);
    framebuffer::set_clip(x0 as u32, y0 as u32, (x1 - x0).max(0) as u32, (y1 - y0).max(0) as u32);
    draw_content_inner(slot);
    framebuffer::clear_clip();
}

fn draw_content_inner(slot: usize) {
    let Some(w) = slot_window(slot) else { return };
    let (wx, wy, dw, dh) = deformed_rect(w);
    let x = (wx.max(0) as u32) + 16;
    let y = (wy.max(0) as u32) + TITLEBAR_H + 14;
    if w.kind == KIND_EDIT {
        editor::draw(x, y, dw.saturating_sub(24), dh.saturating_sub(TITLEBAR_H + 20));
        return;
    }
    if w.kind == KIND_GALLERY {
        gallery::draw(x, y, dw.saturating_sub(24), dh.saturating_sub(TITLEBAR_H + 20));
        return;
    }
    if w.kind == KIND_TERM {
        terminal::draw(x, y, dw.saturating_sub(24), dh.saturating_sub(TITLEBAR_H + 20));
        return;
    }
    if w.kind == KIND_MEDIA {
        media::draw(x, y, dw.saturating_sub(24), dh.saturating_sub(TITLEBAR_H + 20));
        return;
    }
    if w.kind == KIND_DAW {
        crate::drivers::daw::draw(x, y, dw.saturating_sub(24), dh.saturating_sub(TITLEBAR_H + 20));
        return;
    }
    if w.kind == KIND_PKG {
        pkgman::draw(x, y, dw.saturating_sub(24), dh.saturating_sub(TITLEBAR_H + 20));
        return;
    }
    if w.kind == KIND_MESSENGER {
        messenger::draw(x, y, dw.saturating_sub(24), dh.saturating_sub(TITLEBAR_H + 20));
        return;
    }
    if w.kind == KIND_WEB {
        if slot_focused(slot) {
            unsafe { WEB_COLS = (dw.saturating_sub(40) / 8).max(20) as usize };
        }
        // Always-visible URL/search bar across the top of the content.
        let bar_w = dw.saturating_sub(24);
        let bar_h = 22u32;
        let editing = unsafe { WEB_EDITING };
        let border = if editing {
            theme::color(theme::SLOT_ACCENT)
        } else {
            theme::color(theme::SLOT_PANEL_BORDER)
        };
        framebuffer::back_fill_rounded_rect(x, y, bar_w, bar_h, 6, border);
        framebuffer::back_fill_rounded_rect(x + 2, y + 2, bar_w - 4, bar_h - 4, 5, theme::color(theme::SLOT_BG));
        let text = theme::color(theme::SLOT_TEXT);
        let bgc = theme::color(theme::SLOT_BG);
        if editing {
            let buf = unsafe { &(&*&raw const WEB_INPUT)[..WEB_INPUT_LEN] };
            let maxch = ((bar_w - 16) / 8) as usize;
            let shown = buf.len().min(maxch);
            font8x8::draw_str(x + 8, y + 7, &buf[buf.len() - shown..], text, bgc);
            framebuffer::back_fill_rect(x + 8 + shown as u32 * 8, y + 5, 2, 12, theme::color(theme::SLOT_ACCENT));
        } else {
            let url = browser::current_url();
            if url.is_empty() {
                font8x8::draw_str(x + 8, y + 7, b"search or enter a URL", theme::color(theme::SLOT_DIM), bgc);
            } else {
                let ub = url.as_bytes();
                let maxch = ((bar_w - 16) / 8) as usize;
                font8x8::draw_str(x + 8, y + 7, &ub[..ub.len().min(maxch)], theme::color(theme::SLOT_ACCENT), bgc);
            }
        }
        // Page body below the bar.
        browser::draw_page(x, y + bar_h + 6, bar_w, dh.saturating_sub(TITLEBAR_H + bar_h + 26));
        return;
    }
    if w.kind == KIND_HORIZON {
        if slot_focused(slot) {
            unsafe { HZ_VIEWPORT_W = dw.saturating_sub(40).max(160) };
        }
        let bar_w = dw.saturating_sub(24);
        let bar_h = 22u32;
        let editing = unsafe { HZ_EDITING };
        let border = if editing {
            theme::color(theme::SLOT_ACCENT)
        } else {
            theme::color(theme::SLOT_PANEL_BORDER)
        };
        framebuffer::back_fill_rounded_rect(x, y, bar_w, bar_h, 6, border);
        framebuffer::back_fill_rounded_rect(x + 2, y + 2, bar_w - 4, bar_h - 4, 5, theme::color(theme::SLOT_BG));
        let text = theme::color(theme::SLOT_TEXT);
        let bgc = theme::color(theme::SLOT_BG);
        if editing {
            let buf = unsafe { &(&*&raw const HZ_INPUT)[..HZ_INPUT_LEN] };
            let maxch = ((bar_w - 16) / 8) as usize;
            let shown = buf.len().min(maxch);
            font8x8::draw_str(x + 8, y + 7, &buf[buf.len() - shown..], text, bgc);
            framebuffer::back_fill_rect(x + 8 + shown as u32 * 8, y + 5, 2, 12, theme::color(theme::SLOT_ACCENT));
        } else {
            let url = horizon::current_url();
            if url.is_empty() {
                font8x8::draw_str(x + 8, y + 7, b"search or enter a URL", theme::color(theme::SLOT_DIM), bgc);
            } else {
                let ub = url.as_bytes();
                let maxch = ((bar_w - 16) / 8) as usize;
                font8x8::draw_str(x + 8, y + 7, &ub[..ub.len().min(maxch)], theme::color(theme::SLOT_ACCENT), bgc);
            }
        }
        horizon::draw_page(x, y + bar_h + 6, bar_w, dh.saturating_sub(TITLEBAR_H + bar_h + 26));
        return;
    }
    let text = theme::color(theme::SLOT_TEXT);
    let dim = theme::color(theme::SLOT_DIM);
    let panel = theme::color(theme::SLOT_PANEL);
    let accent = theme::color(theme::SLOT_ACCENT);
    match w.kind {
        KIND_ABOUT => {
            font8x8::draw_str(x, y, b"LingOS", accent, panel);
            font8x8::draw_str(x, y + 18, b"a from-scratch OS written in Ling", text, panel);
            let up_s = timer::now_ms() / 1000;
            let mut line = [0u8; 32];
            let msg = b"uptime: ";
            line[..msg.len()].copy_from_slice(msg);
            let mut n = msg.len();
            let secs = up_s % 60;
            let mins = up_s / 60;
            if mins >= 10 {
                line[n] = b'0' + ((mins / 10) % 10) as u8;
                n += 1;
            }
            line[n] = b'0' + (mins % 10) as u8;
            line[n + 1] = b'm';
            line[n + 2] = b' ';
            line[n + 3] = b'0' + (secs / 10) as u8;
            line[n + 4] = b'0' + (secs % 10) as u8;
            line[n + 5] = b's';
            font8x8::draw_str(x, y + 44, &line[..n + 6], dim, panel);
            font8x8::draw_str(x, y + 62, b"theme:", dim, panel);
            font8x8::draw_str(x + 60, y + 62, theme::name(theme::current()).as_bytes(), text, panel);
            font8x8::draw_str(
                x,
                y + 88,
                b"drag titlebars; dock launches; arrows+enter",
                dim,
                panel,
            );
        },
        KIND_SETTINGS => {
            let row_h = 34u32;
            let row_w = dw.saturating_sub(32).max(40);
            let tab = unsafe { SETTINGS_TAB };
            // Tab bar: General | Network.
            let tab_names = [locale::tr("General"), locale::tr("Network"), locale::tr("Audio")];
            for (t, name) in tab_names.iter().enumerate() {
                let (tx, ty, tw, th) = settings_tab_rect(wx, wy, dw, t);
                let active = t == tab;
                let bg = if active { accent } else { theme::color(theme::SLOT_PANEL_BORDER) };
                framebuffer::back_fill_rounded_rect(tx, ty, tw, th, 6, bg);
                framebuffer::back_fill_rounded_rect(tx + 1, ty + 1, tw - 2, th - 2, 5, if active { theme::color(theme::SLOT_TITLEBAR) } else { panel });
                let fg = if active { theme::color(theme::SLOT_TITLEBAR_TEXT) } else { dim };
                font_unicode::draw_utf8_str(tx + 12, ty + 6, name.as_bytes(), fg, if active { theme::color(theme::SLOT_TITLEBAR) } else { panel }, locale::render_daemon());
            }
            let rows_y = y + 40; // below the tab bar

            if tab == SETTINGS_TAB_NETWORK {
                // -- Network tab: interactive rows + read-only status. --------
                let net_labels = [locale::tr("IP mode"), locale::tr("Nameserver"), locale::tr("SSH at boot")];
                for (i, label) in net_labels.iter().enumerate() {
                    let ry = rows_y + i as u32 * row_h;
                    draw_row_ring(x, ry.saturating_sub(6), row_w, row_h - 6, unsafe { SETTINGS_CURSOR == i });
                    font_unicode::draw_utf8_str(x + 10, ry, label.as_bytes(), text, panel, false);
                    let vx = x + 150 * font8x8::scale();
                    match i {
                        0 => {
                            let v: &[u8] = if unsafe { NET_IP_MODE } == 0 { b"DHCP" } else { b"Static (from MAC)" };
                            font8x8::draw_str(vx, ry, v, accent, panel);
                        },
                        1 => {
                            let (name, _, _) = DNS_PRESETS[unsafe { DNS_PRESET } % DNS_PRESETS.len()];
                            font8x8::draw_str(vx, ry, name.as_bytes(), accent, panel);
                        },
                        _ => {
                            let v: &[u8] = if crate::services::ssh_enabled() { b"on" } else { b"off" };
                            font8x8::draw_str(vx, ry, v, accent, panel);
                        },
                    }
                }
                // Read-only status block.
                let mut sy = rows_y + NETWORK_ROWS as u32 * row_h + 10;
                font8x8::draw_str(x + 10, sy, b"Status", dim, panel);
                sy += 20;
                let ip = netstack::self_ip();
                let gw = netstack::gateway_ip();
                let (dns1, _dns2) = netstack::dns_servers();
                font8x8::draw_str(x + 10, sy, b"IP", dim, panel);
                draw_ip(x + 120, sy, ip, accent, panel);
                let link: &[u8] = if ip != [0, 0, 0, 0] { b"link up" } else { b"no link" };
                font8x8::draw_str(x + 260, sy, link, if ip != [0, 0, 0, 0] { accent } else { dim }, panel);
                sy += 18;
                font8x8::draw_str(x + 10, sy, b"Gateway", dim, panel);
                draw_ip(x + 120, sy, gw, text, panel);
                sy += 18;
                font8x8::draw_str(x + 10, sy, b"DNS", dim, panel);
                draw_ip(x + 120, sy, dns1, text, panel);
            } else if tab == SETTINGS_TAB_AUDIO {
                // -- Audio tab: rows + read-only device info + output meter. --
                let aud_labels = [locale::tr("Master volume"), locale::tr("Channels"), locale::tr("Test speakers")];
                for (i, label) in aud_labels.iter().enumerate() {
                    let ry = rows_y + i as u32 * row_h;
                    draw_row_ring(x, ry.saturating_sub(6), row_w, row_h - 6, unsafe { SETTINGS_CURSOR == i });
                    font_unicode::draw_utf8_str(x + 10, ry, label.as_bytes(), text, panel, false);
                    let vx = x + 160 * font8x8::scale();
                    match i {
                        0 => {
                            let mut nb = [0u8; 6];
                            let mut nn = write_u32_into(&mut nb, mixer::master_volume());
                            if nn < nb.len() { nb[nn] = b'%'; nn += 1; }
                            font8x8::draw_str(vx, ry, &nb[..nn], accent, panel);
                        },
                        1 => {
                            let v: &[u8] = if mixer::mono() { b"Mono" } else { b"Stereo" };
                            font8x8::draw_str(vx, ry, v, accent, panel);
                        },
                        _ => font8x8::draw_str(vx, ry, b"left = L ding   right = R ding", accent, panel),
                    }
                }
                // Live output-level meter (L / R).
                let mut sy = rows_y + AUDIO_ROWS as u32 * row_h + 12;
                font8x8::draw_str(x + 10, sy, b"Output level", dim, panel);
                sy += 20;
                let mw = 200u32;
                let dotd = theme::color(theme::SLOT_DOT_DIM);
                font8x8::draw_str(x + 10, sy, b"L", dim, panel);
                framebuffer::back_fill_rounded_rect(x + 40, sy, mw, 8, 3, dotd);
                framebuffer::back_fill_rounded_rect(x + 40, sy, (mixer::output_level_l().min(100) * mw / 100).max(1), 8, 3, accent);
                sy += 16;
                font8x8::draw_str(x + 10, sy, b"R", dim, panel);
                framebuffer::back_fill_rounded_rect(x + 40, sy, mw, 8, 3, dotd);
                framebuffer::back_fill_rounded_rect(x + 40, sy, (mixer::output_level_r().min(100) * mw / 100).max(1), 8, 3, accent);
                // Honest read-only device facts.
                sy += 28;
                font8x8::draw_str(x + 10, sy, b"Device: AC'97   48000 Hz   16-bit   stereo", dim, panel);
                sy += 18;
                let mut bb = [0u8; 56];
                let mut bn = 0;
                for &c in b"Buffer: " { bb[bn] = c; bn += 1; }
                bn += write_u32_into(&mut bb[bn..], mixer::buffer_frames());
                for &c in b" frames x " { if bn < bb.len() { bb[bn] = c; bn += 1; } }
                bn += write_u32_into(&mut bb[bn..], mixer::ring_buffers());
                for &c in b" ring (gap-free)" { if bn < bb.len() { bb[bn] = c; bn += 1; } }
                font8x8::draw_str(x + 10, sy, &bb[..bn], dim, panel);
                sy += 18;
                let inp: &[u8] = if mixer::has_capture() { b"Input: capture device present" } else { b"Input: none (this AC'97 is playback-only)" };
                font8x8::draw_str(x + 10, sy, inp, dim, panel);
            } else {
                // -- General tab: scrollable list. ----------------------------
                let labels: [&[u8]; GENERAL_ROWS] = [
                    b"UI theme",
                    b"Sound theme",
                    b"Wallpaper",
                    b"Display",
                    b"Window spring",
                    b"Keyboard",
                    b"Language",
                    b"Clock",
                    b"DPI",
                    b"Refresh",
                    b"Wall scale",
                ];
                let scroll = unsafe { SETTINGS_SCROLL };
                for vis in 0..SETTINGS_VISIBLE {
                    let i = scroll + vis;
                    if i >= GENERAL_ROWS {
                        break;
                    }
                    let label = labels[i];
                    let ry = rows_y + vis as u32 * row_h;
                    draw_row_ring(x, ry.saturating_sub(6), row_w, row_h - 6, unsafe { SETTINGS_CURSOR == i });
                    font8x8::draw_str(x + 10, ry, label, text, panel);
                    let vx = x + 130 * font8x8::scale();
                    match i {
                        0 => font8x8::draw_str(vx, ry, theme::name(theme::current()).as_bytes(), accent, panel),
                        1 => font8x8::draw_str(vx, ry, mixer::sound_theme_name(mixer::sound_theme()).as_bytes(), accent, panel),
                        2 => font8x8::draw_str(vx, ry, wallpaper_name(unsafe { WALLPAPER }).as_bytes(), accent, panel),
                        3 => {
                            font8x8::draw_str(vx, ry, display::mode_label(display::selected()).as_bytes(), accent, panel);
                            let sx = vx + 130 * font8x8::scale();
                            font8x8::draw_str(sx, ry, display::current_str().as_bytes(), dim, panel);
                            let note: &[u8] = if framebuffer::dispi_capable() { b"(Enter=apply)" } else { b"(next boot)" };
                            font8x8::draw_str(sx, ry + 14 * font8x8::scale(), note, dim, panel);
                        },
                        4 => {
                            let milli = spring_stiffness_milli();
                            let mut nb = [0u8; 8];
                            let mut nn = 0;
                            nb[nn] = b'0'; nb[nn + 1] = b'.'; nn += 2;
                            nb[nn] = b'0' + ((milli / 10) % 10) as u8; nn += 1;
                            nb[nn] = b'0' + (milli % 10) as u8; nn += 1;
                            font8x8::draw_str(vx, ry, &nb[..nn], accent, panel);
                            let bx = vx + 60;
                            framebuffer::back_fill_rounded_rect(bx, ry + 2, 100, 6, 3, theme::color(theme::SLOT_DOT_DIM));
                            let fill = (milli.saturating_sub(SPRING_K_MIN)) * 100 / (SPRING_K_MAX - SPRING_K_MIN);
                            framebuffer::back_fill_rounded_rect(bx, ry + 2, fill.max(1), 6, 3, accent);
                        },
                        5 => font8x8::draw_str(vx, ry, kbdlayout::name(kbdlayout::current()).as_bytes(), accent, panel),
                        6 => {
                            let li = locale::selected().unwrap_or(0);
                            if let Some(l) = locale::get(li) {
                                if l.uses_daemon_script {
                                    font8x8::draw_str(vx, ry, l.latin_name.as_bytes(), accent, panel);
                                } else {
                                    font_unicode::draw_utf8_str(vx, ry, l.native_name.as_bytes(), accent, panel, false);
                                }
                            }
                        },
                        7 => {
                            let v: &[u8] = if unsafe { CLOCK_24H } { b"24-hour" } else { b"12-hour" };
                            font8x8::draw_str(vx, ry, v, accent, panel);
                        },
                        8 => {
                            let mut nb = [0u8; 8];
                            let mut nn = write_u32_into(&mut nb, ui_scale::dpi_pct());
                            if nn < nb.len() { nb[nn] = b'%'; nn += 1; }
                            font8x8::draw_str(vx, ry, &nb[..nn], accent, panel);
                        },
                        9 => {
                            let hz = framebuffer::present_hz();
                            if hz == 0 {
                                font8x8::draw_str(vx, ry, b"uncapped", accent, panel);
                            } else {
                                let mut nb = [0u8; 10];
                                let mut nn = write_u32_into(&mut nb, hz);
                                for &c in b" Hz" { if nn < nb.len() { nb[nn] = c; nn += 1; } }
                                font8x8::draw_str(vx, ry, &nb[..nn], accent, panel);
                            }
                        },
                        _ => font8x8::draw_str(vx, ry, wallpaper::scale_mode_name(wallpaper::scale_mode()).as_bytes(), accent, panel),
                    }
                }
                let sb_x = x + row_w.saturating_sub(6);
                if scroll > 0 {
                    font8x8::draw_str(sb_x, rows_y, b"^", dim, panel);
                }
                if scroll + SETTINGS_VISIBLE < GENERAL_ROWS {
                    font8x8::draw_str(sb_x, rows_y + (SETTINGS_VISIBLE as u32 - 1) * row_h, b"v", dim, panel);
                }
            }

            let hint_y = rows_y + SETTINGS_VISIBLE as u32 * row_h + 8;
            font8x8::draw_str(x, hint_y, b"tab: switch tab   arrows: change   enter: apply", dim, panel);
            // Apply / OK buttons (mouse-clickable; see content_click).
            let (ax, ay, aw, ah) = settings_apply_rect(wx, wy, dw, dh);
            framebuffer::back_fill_rounded_rect(ax, ay, aw, ah, 6, theme::color(theme::SLOT_PANEL_BORDER));
            framebuffer::back_fill_rounded_rect(ax + 1, ay + 1, aw - 2, ah - 2, 5, panel);
            font_unicode::draw_utf8_str(ax + 18, ay + 9, locale::tr("Apply").as_bytes(), text, panel, locale::render_daemon());
            let (ox, oy, ow, oh) = settings_ok_rect(wx, wy, dw, dh);
            framebuffer::back_fill_rounded_rect(ox, oy, ow, oh, 6, accent);
            framebuffer::back_fill_rounded_rect(ox + 1, oy + 1, ow - 2, oh - 2, 5, theme::color(theme::SLOT_TITLEBAR));
            font_unicode::draw_utf8_str(ox + 28, oy + 9, locale::tr("OK").as_bytes(), theme::color(theme::SLOT_TITLEBAR_TEXT), theme::color(theme::SLOT_TITLEBAR), locale::render_daemon());
        },
        KIND_FILES => {
            unsafe {
                if FM_VIEWING {
                    let name =
                        core::str::from_utf8(&(&*&raw const FM_VIEW_NAME)[..FM_VIEW_NAME_LEN])
                            .unwrap_or("?");
                    font8x8::draw_str(x, y, name.as_bytes(), accent, panel);
                    let mut content = [0u8; 4096];
                    match lingfs::read_file(name, &mut content) {
                        Ok(Some(len)) => {
                            // First ~12 lines, 8px font, CR/LF aware.
                            let mut line_y = y + 20;
                            let mut col = 0u32;
                            let max_cols = dw.saturating_sub(48) / 8;
                            for &b in &content[..len] {
                                if line_y > y + 20 + 12 * 14 {
                                    break;
                                }
                                if b == b'\n' || col >= max_cols {
                                    line_y += 14;
                                    col = 0;
                                    if b == b'\n' {
                                        continue;
                                    }
                                }
                                if b == b'\r' {
                                    continue;
                                }
                                font8x8::draw_char(x + col * 8, line_y, b, text, panel);
                                col += 1;
                            }
                        },
                        _ => {
                            font_unicode::draw_utf8_str(x, y + 20, locale::tr("(unreadable)").as_bytes(), dim, panel, locale::render_daemon());
                        },
                    }
                    font_unicode::draw_utf8_str(x, y + 20 + 13 * 14, locale::tr("backspace: back").as_bytes(), dim, panel, locale::render_daemon());
                    return;
                }
                // Directory listing.
                let header: &[u8] = if FM_DIR_LEN == 0 { b"/" } else { fm_dir().as_bytes() };
                font8x8::draw_str(x, y, header, accent, panel);
                if FM_DIR_LEN > 0 {
                    font8x8::draw_str(x + dw.saturating_sub(96), y, b"[ up ]", dim, panel);
                }
                let count = fm_entry_count();
                if count == 0 {
                    font_unicode::draw_utf8_str(x, y + 22, locale::tr("(empty)").as_bytes(), dim, panel, locale::render_daemon());
                }
                let row_h = 24u32;
                let row_w = dw.saturating_sub(32).max(40);
                let mut buf = [0u8; FM_NAME_MAX];
                for vis in 0..FM_VISIBLE_ROWS {
                    let i = FM_SCROLL + vis;
                    let Some((len, is_dir)) = fm_entry(i, &mut buf) else {
                        break;
                    };
                    let ry = y + 22 + vis as u32 * row_h;
                    draw_row_ring(x, ry.saturating_sub(5), row_w, row_h - 4, FM_CURSOR == i);
                    draw_fm_icon(x + 6, ry.saturating_sub(3), &buf[..len], is_dir);
                    font8x8::draw_str(x + 32, ry, &buf[..len], text, panel);
                }
                let foot = y + 22 + FM_VISIBLE_ROWS as u32 * row_h + 6;
                if !FM_MSG.is_empty() {
                    font8x8::draw_str(x, foot - 14, FM_MSG.as_bytes(), 0xE0A44A, panel);
                }
                font_unicode::draw_utf8_str(
                    x,
                    foot,
                    locale::tr("double-click/enter: open   e: edit   backspace: up   h: home").as_bytes(),
                    dim,
                    panel,
                    locale::render_daemon(),
                );
            }
        },
        KIND_WALLPICK => {
            let pane = unsafe { WP_PANE };
            let left_w = 170u32;
            // Left pane: places (drives / common folders).
            font8x8::draw_str(x, y, b"Places", dim, panel);
            for (i, (label, _)) in WP_SHORTCUTS.iter().enumerate() {
                let ry = y + 24 + i as u32 * 26;
                let sel = pane == 0 && unsafe { WP_LEFT } == i;
                draw_row_ring(x, ry.saturating_sub(5), left_w, 22, sel);
                font8x8::draw_str(x + 10, ry, label.as_bytes(), if sel { accent } else { text }, panel);
            }
            // Divider between the panes.
            framebuffer::back_fill_rect(x + left_w + 8, y, 1, dh.saturating_sub(TITLEBAR_H + 44), theme::color(theme::SLOT_PANEL_BORDER));
            // Right pane: the current folder's entries.
            let rx = x + left_w + 22;
            let rw = dw.saturating_sub(left_w + 44);
            let header: &[u8] = if unsafe { WP_DIR_LEN } == 0 { b"/" } else { wp_dir().as_bytes() };
            font8x8::draw_str(rx, y, header, accent, panel);
            let row_h = 22u32;
            let scroll = unsafe { WP_SCROLL };
            let mut buf = [0u8; FM_NAME_MAX];
            for vis in 0..WP_VISIBLE {
                let i = scroll + vis;
                let Some((len, is_dir)) = lingfs::list_entry(wp_dir(), i, &mut buf) else { break };
                let ry = y + 24 + vis as u32 * row_h;
                let sel = pane == 1 && unsafe { WP_RIGHT } == i;
                draw_row_ring(rx, ry.saturating_sub(4), rw, row_h - 3, sel);
                let is_bmp = len >= 4 && &buf[len - 4..len] == b".bmp";
                let ic = if is_dir { accent } else if is_bmp { 0x5AC06A } else { dim };
                framebuffer::back_fill_rounded_rect(rx + 6, ry + 1, 12, 10, 2, ic);
                font8x8::draw_str(rx + 26, ry, &buf[..len], if sel { accent } else { text }, panel);
            }
            let hy = y + 24 + WP_VISIBLE as u32 * row_h + 8;
            font8x8::draw_str(x, hy, unsafe { WP_STATUS }.as_bytes(), dim, panel);
        },
        _ => {},
    }
}

/// Deterministic per-cell hash in 0.0..1.0 for the dissolve pattern (no
/// Math.random on this target; a hash of the cell coords gives a stable,
/// ragged sublimation edge that looks the same every frame of one close).
fn cell_hash(cx: u32, cy: u32) -> f64 {
    let mut h = cx.wrapping_mul(374761393).wrapping_add(cy.wrapping_mul(668265263));
    h ^= h >> 13;
    h = h.wrapping_mul(1274126177);
    h ^= h >> 16;
    (h & 0xFFFF) as f64 / 65535.0
}

/// Render a whole window kernel-side: soft shadow, a rounded body that
/// *bends* (per-scanline horizontal shear driven by `bend`, so a dragged
/// window leans and wobbles like a soap film), the titlebar with title +
/// traffic-light buttons, and -- when not mid-dissolve -- its content. A
/// closing window sublimates instead: a diagonal hash-sweep of vanishing
/// cells plus an overall fade. This replaces the old `.ling` rect drawing
/// (which couldn't shear or dissolve) -- `main.ling` now just calls this
/// once per z slot.
pub fn draw_window(slot: usize) {
    let Some(w) = slot_window(slot) else { return };
    // Aero-snap ghost: while this window is the one being dragged near an
    // edge, show where it would dock. Drawn before the window so it sits
    // behind it (the ghost is at the edge, the window at the cursor).
    unsafe {
        let z = &*&raw const Z;
        if slot < Z_LEN && z[slot] as i32 == DRAG_WIN {
            if let Some((sx, sy, sw, sh)) = SNAP_PREVIEW {
                framebuffer::back_blend_rounded_rect(
                    sx,
                    sy,
                    sw,
                    sh,
                    10,
                    theme::color(theme::SLOT_ACCENT),
                    70,
                );
            }
        }
    }
    let (x0, y0, dw, dh) = deformed_rect(w);
    if dw < 8 || dh < 8 {
        return;
    }
    let x0 = x0.max(-(dw as i64));
    let bend = w.bend;
    let dissolve = w.dissolve;
    let fade = ((1.0 - dissolve) * 255.0).clamp(0.0, 255.0) as u32;
    let focused = slot_focused(slot);
    let radius = 10u32;
    let tb = TITLEBAR_H;

    let border = if focused {
        theme::color(theme::SLOT_ACCENT)
    } else {
        theme::color(theme::SLOT_PANEL_BORDER)
    };
    let body = theme::color(theme::SLOT_PANEL);
    let title_bg = if focused {
        theme::color(theme::SLOT_TITLEBAR)
    } else {
        theme::color(theme::SLOT_TITLEBAR_IDLE)
    };

    // Per-scanline shear: a parallelogram lean around the vertical center,
    // amplitude `bend`. `xoff(dy)` in pixels.
    let shear = |dy: u32| -> i64 { (bend * (dy as f64 / dh as f64 - 0.5) * 2.0) as i64 };

    // Shadow (skipped once mostly dissolved to save the blend cost).
    if dissolve < 0.6 {
        let sh_alpha = (70 * fade / 255).min(70);
        framebuffer::back_blend_rounded_rect(
            (x0 + 6).max(0) as u32,
            (y0 + 8).max(0) as u32,
            dw,
            dh,
            radius,
            theme::color(theme::SLOT_SHADOW),
            sh_alpha,
        );
    }

    let cell = 9u32; // dissolve cell size
    for dy in 0..dh {
        let inset = framebuffer::rounded_row_inset(dy, dh, radius).min(dw / 2);
        let rx = x0 + inset as i64 + shear(dy);
        let ry = y0 + dy as i64;
        if ry < 0 {
            continue;
        }
        let row_w = dw - inset * 2;
        let (fill, is_edge) = if dy < tb {
            (title_bg, false)
        } else {
            (body, false)
        };
        let _ = is_edge;
        if dissolve <= 0.0 {
            // Fast opaque path: 1px border frame + inner fill.
            framebuffer::back_fill_rect(rx.max(0) as u32, ry as u32, row_w, 1, border);
            if dy >= 1 && dy + 1 < dh && row_w > 2 {
                framebuffer::back_fill_rect((rx + 1).max(0) as u32, ry as u32, row_w - 2, 1, fill);
            }
        } else {
            // Sublimating: draw cell by cell, skipping vanished cells and
            // fading the survivors. Diagonal sweep from top-left + hash.
            let mut cxp = 0u32;
            while cxp < row_w {
                let px = rx + cxp as i64;
                let cw = cell.min(row_w - cxp);
                let dprog = (cxp as f64 / dw as f64) * 0.5 + (dy as f64 / dh as f64) * 0.5;
                let gone = dprog + cell_hash(cxp / cell, dy / cell) * 0.5 < dissolve * 1.6;
                if !gone && px >= 0 {
                    let c = if dy < tb { title_bg } else { fill };
                    framebuffer::back_blend_rect(px as u32, ry as u32, cw, 1, c, fade);
                }
                cxp += cw;
            }
        }
    }

    // Titlebar text + traffic lights (fade out as it dissolves).
    if dissolve < 0.55 {
        let tx = (x0 + 12 + shear(6)).max(0) as u32;
        let title = kind_title(w.kind);
        let ttext = if focused {
            theme::color(theme::SLOT_TITLEBAR_TEXT)
        } else {
            theme::color(theme::SLOT_DIM)
        };
        font_unicode::draw_utf8_str(tx, (y0 + 8).max(0) as u32, locale::tr(title).as_bytes(), ttext, title_bg, locale::render_daemon());
        // maximize (green) + minimize (amber) + close (red) at the right end.
        let cyb = (y0 + tb as i64 / 2).max(0) as u32;
        let close_x = x0 + dw as i64 - tb as i64 / 2 + shear(tb / 2);
        let min_x = x0 + dw as i64 - tb as i64 - tb as i64 / 2 + shear(tb / 2);
        let max_x = x0 + dw as i64 - 2 * tb as i64 - tb as i64 / 2 + shear(tb / 2);
        if max_x >= 0 {
            framebuffer::back_fill_circle(max_x as u32, cyb, 6, 0x5AC06A);
        }
        if min_x >= 0 {
            framebuffer::back_fill_circle(min_x as u32, cyb, 6, 0xE0A44A);
        }
        if close_x >= 0 {
            framebuffer::back_fill_circle(close_x as u32, cyb, 6, 0xE0605A);
        }
    }

    // Content: only while the window is coherent (not mid-dissolve). The
    // bend is left off the content (it stays axis-aligned inside the bent
    // body) -- the amplitude is small enough that text stays within it.
    if dissolve <= 0.0 {
        draw_content(slot);
    }
}

← Back to index