ling-lang
↓ 0 downloadsLing - The Omniglot Systems Language
Keywords systems, polyglot, ai, crypto, game
lingfu install ling-langCode / 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);
}
}