//! Window manager — a shared facility (no dedicated task). //! //! Implements ADR 0004: overlapping windows backed by per-window pixel //! buffers, composited onto the canvas with window-granularity damage //! rectangles. The home terminal is window 0; GUI windows are composited //! above it. Chrome (title bars) is WM-drawn; each window's buffer is its //! content only. use core::sync::atomic::{AtomicUsize, Ordering}; use crate::gop; use crate::memory; use crate::ps2::{AppMouseEvent, KeyEvent, MouseKind}; use crate::sched::TaskMsg; /// Height of a window's title bar in pixels (one 8×16 glyph row). pub const TITLE_BAR_H: usize = 16; /// Sentinel: `FOCUSED == CONSOLE` means the console (vt) has focus. pub const CONSOLE: usize = usize::MAX; const MAX_WINDOWS: usize = 16; const MIN_W: usize = 64; const MIN_H: usize = 64; const CASCADE: usize = 24; const TITLE_MAX: usize = 32; // Palette (RGB; `gop::rgb` packs for the current pixel format). const TITLE_BG: (u8, u8, u8) = (0x3a, 0x3c, 0x42); const TITLE_BG_FOCUS: (u8, u8, u8) = (0x28, 0x4a, 0x8c); const TITLE_FG: (u8, u8, u8) = (0xff, 0xff, 0xff); const CLOSE_BG: (u8, u8, u8) = (0xb0, 0x30, 0x30); const MIN_BG: (u8, u8, u8) = (0x50, 0x50, 0x50); const DESKTOP: (u8, u8, u8) = (0x18, 0x1a, 0x1e); #[derive(Clone, Copy, PartialEq, Eq)] enum WinState { Visible, Minimized, } #[derive(Clone, Copy, PartialEq, Eq)] enum Hit { None, Close, Minimize, Title, Content, } #[derive(Clone, Copy, PartialEq, Eq)] enum Drag { None, Move, App, } #[derive(Clone, Copy)] struct Window { used: bool, state: WinState, title: [u8; TITLE_MAX], title_len: usize, owner: usize, x: usize, y: usize, w: usize, h: usize, buf: *mut u32, chrome: bool, } const EMPTY: Window = Window { used: false, state: WinState::Visible, title: [0; TITLE_MAX], title_len: 0, owner: usize::MAX, x: 0, y: 0, w: 0, h: 0, buf: core::ptr::null_mut(), chrome: true, }; static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY; MAX_WINDOWS]; static mut ZORDER: [usize; MAX_WINDOWS] = [usize::MAX; MAX_WINDOWS]; static mut ZLEN: usize = 0; static FOCUSED: AtomicUsize = AtomicUsize::new(CONSOLE); static mut CASCADE_X: usize = 0; static mut CASCADE_Y: usize = 0; // --------------------------------------------------------------------------- // Mouse gesture state // --------------------------------------------------------------------------- static mut DRAG: Drag = Drag::None; static mut DRAG_ID: usize = usize::MAX; static mut DRAG_DX: i32 = 0; static mut DRAG_DY: i32 = 0; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Initialise window state. pub fn init() { unsafe { ZLEN = 0; CASCADE_X = 0; CASCADE_Y = 0; } FOCUSED.store(CONSOLE, Ordering::Relaxed); } /// Create window 0 (the home terminal): full-screen, chrome-less, /// bottom-pinned, never closed. pub fn create_home_window(buf: *mut u32, w: usize, h: usize) -> usize { let id = 0; unsafe { WINDOWS[id] = Window { used: true, state: WinState::Visible, title: [0; TITLE_MAX], title_len: 0, owner: usize::MAX, x: 0, y: 0, w, h, buf, chrome: false, }; ZORDER[0] = id; ZLEN = 1; } id } pub fn focused() -> Option { let v = FOCUSED.load(Ordering::Relaxed); if v == CONSOLE || v == 0 { None } else { Some(v) } } fn get(id: usize) -> Option { if id >= MAX_WINDOWS { return None; } let w = unsafe { WINDOWS[id] }; if w.used { Some(w) } else { None } } /// Create a normal (chrome) window. pub fn create_window(title: &str, w: usize, h: usize) -> Option { let (cw, ch) = gop::pixel_dims(); let w = w.max(MIN_W).min(cw.saturating_sub(8)); let h = h.max(MIN_H).min(ch.saturating_sub(TITLE_BAR_H + 8)); if w < MIN_W || h < MIN_H || cw == 0 || ch == 0 { return None; } let pages = (w * h * 4).div_ceil(4096); let buf = memory::alloc_pages(pages).map(|a| a as *mut u32)?; unsafe { for i in 0..(w * h) { *buf.add(i) = 0; } } let id = unsafe { let mut slot = None; for i in 0..MAX_WINDOWS { if !WINDOWS[i].used { slot = Some(i); break; } } match slot { Some(i) => i, None => { memory::free_pages(buf as usize, pages); return None; } } }; let (x, y) = unsafe { let mut px = CASCADE_X; let mut py = CASCADE_Y; if px + w > cw { px = 0; } if py + TITLE_BAR_H + h > ch { py = 0; } CASCADE_X = px + CASCADE; CASCADE_Y = py + CASCADE; (px, py) }; let mut tl = [0u8; TITLE_MAX]; let n = title.len().min(TITLE_MAX); tl[..n].copy_from_slice(&title.as_bytes()[..n]); unsafe { WINDOWS[id] = Window { used: true, state: WinState::Visible, title: tl, title_len: n, owner: crate::sched::current(), x, y, w, h, buf, chrome: true, }; ZORDER[ZLEN] = id; ZLEN += 1; } focus(id); Some(id) } pub fn flush(id: usize) { let Some(win) = get(id) else { return; }; let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; let pad = if id != 0 { 1 } else { 0 }; composite_rect( win.x.saturating_sub(pad), win.y.saturating_sub(pad), win.w + 2 * pad, outer_h + 2 * pad, ); } pub fn destroy(id: usize) { if id == 0 { return; } let Some(win) = get(id) else { return; }; let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; let (x, y, w, h) = (win.x, win.y, win.w, outer_h); if !win.buf.is_null() { let pages = (win.w * win.h * 4).div_ceil(4096); memory::free_pages(win.buf as usize, pages); } remove_from_zorder(id); unsafe { WINDOWS[id] = EMPTY; } if focused() == Some(id) { if unsafe { ZLEN } > 0 { focus(unsafe { ZORDER[ZLEN - 1] }); } else { focus_console(); } } composite_rect(x, y, w, h); } pub fn minimize(id: usize) { let Some(win) = get(id) else { return; }; if win.state == WinState::Minimized || id == 0 { return; } let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; let (x, y, w, h) = (win.x, win.y, win.w, outer_h); unsafe { WINDOWS[id].state = WinState::Minimized; } remove_from_zorder(id); if focused() == Some(id) { focus_console(); } composite_rect(x, y, w, h); } pub fn restore(id: usize) { let Some(win) = get(id) else { return; }; if win.state == WinState::Visible { return; } unsafe { WINDOWS[id].state = WinState::Visible; ZORDER[ZLEN] = id; ZLEN += 1; } focus(id); let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; composite_rect(win.x, win.y, win.w, outer_h); } pub fn focus(id: usize) { let Some(win) = get(id) else { return; }; if win.state != WinState::Visible { return; } let prev = focused(); // Window 0 (home terminal) is the console — store the CONSOLE // sentinel so `focused()` returns None and the tty layer reads // keyboard input directly (same as focus_console). if id == 0 { FOCUSED.store(CONSOLE, Ordering::Relaxed); } else { FOCUSED.store(id, Ordering::Relaxed); } if id != 0 { remove_from_zorder(id); unsafe { ZORDER[ZLEN] = id; ZLEN += 1; } } if let Some(p) = prev && p != id && let Some(pw) = get(p) { let _ = crate::sched::send(pw.owner, TaskMsg::Focus(false)); // Redraw old window: full content + border area. let oh = pw.h + if pw.chrome { TITLE_BAR_H } else { 0 }; let pad = if p != 0 { 1 } else { 0 }; composite_rect( pw.x.saturating_sub(pad), pw.y.saturating_sub(pad), pw.w + 2 * pad, oh + 2 * pad, ); } let _ = crate::sched::send(win.owner, TaskMsg::Focus(true)); // Redraw new window: full content + border area. let oh = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; let pad = if id != 0 { 1 } else { 0 }; composite_rect( win.x.saturating_sub(pad), win.y.saturating_sub(pad), win.w + 2 * pad, oh + 2 * pad, ); } pub fn focus_console() { let prev = focused(); FOCUSED.store(CONSOLE, Ordering::Relaxed); if let Some(p) = prev && let Some(pw) = get(p) { let _ = crate::sched::send(pw.owner, TaskMsg::Focus(false)); let oh = pw.h + if pw.chrome { TITLE_BAR_H } else { 0 }; let pad = if p != 0 { 1 } else { 0 }; composite_rect( pw.x.saturating_sub(pad), pw.y.saturating_sub(pad), pw.w + 2 * pad, oh + 2 * pad, ); } } pub fn focus_next() { unsafe { if ZLEN == 0 { focus_console(); return; } match focused() { None => focus(ZORDER[ZLEN - 1]), Some(cur) => { let mut idx = usize::MAX; for i in 0..ZLEN { if ZORDER[i] == cur { idx = i; break; } } if idx == usize::MAX || idx == 0 { focus_console(); } else { focus(ZORDER[idx - 1]); } } } } } pub fn handle_key(ev: KeyEvent) -> bool { match ev { KeyEvent::F(1) => { spawn_terminal(); true } KeyEvent::F(2) => { focus_next(); true } KeyEvent::F(3) => { if let Some(id) = focused() { minimize(id); } true } KeyEvent::F(4) => { if let Some(id) = focused() { request_close(id); } true } _ => false, } } static TERM_COUNT: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); /// Callback set by rsdos to spawn a terminal task. Takes (title, w, h). pub static mut SPAWN_TERMINAL_CB: Option = None; fn spawn_terminal() { let Some(cb) = (unsafe { SPAWN_TERMINAL_CB }) else { crate::serial_println!("wm: SPAWN_TERMINAL_CB not set"); return; }; let n = TERM_COUNT.fetch_add(1, Ordering::Relaxed) + 1; let title = alloc::format!("term {}", n); let (cw, ch) = gop::pixel_dims(); let w = 640.min(cw); let h = 400.min(ch); cb(&title, w, h); } fn remove_from_zorder(id: usize) { unsafe { let mut j = 0; for i in 0..ZLEN { if ZORDER[i] != id { ZORDER[j] = ZORDER[i]; j += 1; } } ZLEN = j; } } // --------------------------------------------------------------------------- // Hit-testing, mouse routing, and drag state // --------------------------------------------------------------------------- fn hit_test(id: usize, x: usize, y: usize) -> Hit { let Some(w) = get(id) else { return Hit::None; }; if !w.chrome { return Hit::Content; } if y >= w.y + TITLE_BAR_H { return Hit::Content; } let right = w.x + w.w; if x >= right.saturating_sub(8) { return Hit::Close; } if x >= right.saturating_sub(16) { return Hit::Minimize; } Hit::Title } pub fn window_at(x: usize, y: usize) -> Option { unsafe { for i in (0..ZLEN).rev() { let id = ZORDER[i]; let w = WINDOWS[id]; if !w.used || w.state != WinState::Visible { continue; } let outer_h = w.h + if w.chrome { TITLE_BAR_H } else { 0 }; if x >= w.x && x < w.x + w.w && y >= w.y && y < w.y + outer_h { return Some(id); } } } None } pub fn request_close(id: usize) { if let Some(w) = get(id) { let _ = crate::sched::send(w.owner, TaskMsg::Close); } } pub fn route_mouse(x: i32, y: i32, buttons: u8, kind: MouseKind) { match kind { MouseKind::Press => mouse_press(x, y, buttons), MouseKind::Move => mouse_move(x, y, buttons), MouseKind::Release => mouse_release(x, y, buttons), } } fn mouse_press(x: i32, y: i32, buttons: u8) { if x < 0 || y < 0 { return; } let (cx, cy) = (x as usize, y as usize); if let Some(id) = window_at(cx, cy) { match hit_test(id, cx, cy) { Hit::Close => request_close(id), Hit::Minimize => minimize(id), Hit::Title => { focus(id); if let Some(w) = get(id) { unsafe { DRAG = Drag::Move; DRAG_ID = id; DRAG_DX = cx as i32 - w.x as i32; DRAG_DY = cy as i32 - w.y as i32; } } } Hit::Content => { focus(id); unsafe { DRAG = Drag::App; DRAG_ID = id; } deliver_mouse(id, cx, cy, buttons, MouseKind::Press); } Hit::None => {} } } else { focus_console(); } } fn mouse_move(x: i32, y: i32, buttons: u8) { match unsafe { DRAG } { Drag::Move => { let id = unsafe { DRAG_ID }; let Some(w) = get(id) else { unsafe { DRAG = Drag::None } return; }; let nx = (x - unsafe { DRAG_DX }).max(0) as usize; let ny = (y - unsafe { DRAG_DY }).max(0) as usize; if nx != w.x || ny != w.y { move_window(id, nx, ny); } } Drag::App => { let id = unsafe { DRAG_ID }; deliver_mouse(id, x as usize, y as usize, buttons, MouseKind::Move); } Drag::None => {} } } fn mouse_release(x: i32, y: i32, buttons: u8) { if unsafe { DRAG } == Drag::App { let id = unsafe { DRAG_ID }; deliver_mouse(id, x as usize, y as usize, buttons, MouseKind::Release); } unsafe { DRAG = Drag::None; DRAG_ID = usize::MAX; } } fn deliver_mouse(id: usize, cx: usize, cy: usize, buttons: u8, kind: MouseKind) { let Some(w) = get(id) else { return; }; let content_y = w.y + if w.chrome { TITLE_BAR_H } else { 0 }; let rx = cx as i32 - w.x as i32; let ry = cy as i32 - content_y as i32; if kind != MouseKind::Release && (rx < 0 || ry < 0 || rx >= w.w as i32 || ry >= w.h as i32) { return; } let ev = AppMouseEvent { x: rx, y: ry, buttons, kind, }; let _ = crate::sched::send(w.owner, TaskMsg::AppMouse(ev)); } fn move_window(id: usize, nx: usize, ny: usize) { if id == 0 { return; } let Some(w) = get(id) else { return; }; let (cw, ch) = gop::pixel_dims(); let nx = nx.min(cw.saturating_sub(w.w)); let ny = ny.min(ch.saturating_sub(w.h + TITLE_BAR_H)); if nx == w.x && ny == w.y { return; } let (ox, oy) = (w.x, w.y); unsafe { WINDOWS[id].x = nx; WINDOWS[id].y = ny; } let pad: usize = 1; let oh = w.h + TITLE_BAR_H; composite_rect(ox.saturating_sub(pad), oy.saturating_sub(pad), w.w + 2 * pad, oh + 2 * pad); composite_rect(nx.saturating_sub(pad), ny.saturating_sub(pad), w.w + 2 * pad, oh + 2 * pad); } pub fn owner(id: usize) -> Option { get(id).map(|w| w.owner) } pub fn buffer(id: usize) -> Option<(*mut u32, usize, usize)> { get(id).map(|w| (w.buf, w.w, w.h)) } pub fn list() -> alloc::vec::Vec<(usize, alloc::string::String, bool)> { let mut out = alloc::vec::Vec::new(); for i in 0..MAX_WINDOWS { let w = unsafe { WINDOWS[i] }; if w.used && i != 0 { let title = alloc::string::String::from( core::str::from_utf8(&w.title[..w.title_len]).unwrap_or("?"), ); out.push((i, title, w.state == WinState::Minimized)); } } out } pub fn spawn_input_pump() -> Option { crate::sched::spawn(pump_main, "wm-input") } fn pump_main() { loop { if let Some(id) = focused() { let o = owner(id).unwrap_or(0); while let Some(ev) = crate::ps2::try_poll_key().or_else(crate::uhci::try_poll_key) { if !handle_key(ev) { let _ = crate::sched::send(o, TaskMsg::Key(ev)); } } } crate::sched::sleep_ms(5); } } // --------------------------------------------------------------------------- // Compositing // --------------------------------------------------------------------------- /// Composite a region: fill background, blit windows bottom-to-top, push. fn composite_rect(x: usize, y: usize, w: usize, h: usize) { gop::shadow_fill_rect(x, y, w, h, gop::rgb(DESKTOP.0, DESKTOP.1, DESKTOP.2)); unsafe { for i in 0..ZLEN { draw_window_intersect(ZORDER[i], x, y, w, h); } } gop::push_rect(x, y, w, h); } /// Blit the portion of a window that intersects `(rx, ry, rw, rh)`. /// 1px border drawn around every window except the root (id 0). /// Blue when focused, grey when not. fn draw_window_border(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) { if id == 0 { return; } let win = unsafe { WINDOWS[id] }; if !win.used || win.state != WinState::Visible { return; } let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; let bg = if focused() == Some(id) { TITLE_BG_FOCUS } else { TITLE_BG }; let bc = gop::rgb(bg.0, bg.1, bg.2); // Border sits 1px outside the window's outer rect. let bx = win.x.saturating_sub(1); let by = win.y.saturating_sub(1); let bw = win.w + 2; let bh = outer_h + 2; let ix = bx.max(rx); let iy = by.max(ry); let ix2 = (bx + bw).min(rx + rw); let iy2 = (by + bh).min(ry + rh); if ix >= ix2 || iy >= iy2 { return; } // Four edges. gop::shadow_fill_rect(ix, iy, ix2 - ix, 1, bc); // top gop::shadow_fill_rect(ix, iy2 - 1, ix2 - ix, 1, bc); // bottom gop::shadow_fill_rect(ix, iy + 1, 1, iy2 - iy - 2, bc); // left gop::shadow_fill_rect(ix2 - 1, iy + 1, 1, iy2 - iy - 2, bc); // right } /// Blit the portion of a window that intersects `(rx, ry, rw, rh)`. fn draw_window_intersect(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) { let win = unsafe { WINDOWS[id] }; if !win.used || win.state != WinState::Visible || win.buf.is_null() { return; } // Draw border first (under the window content). draw_window_border(id, rx, ry, rw, rh); let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; let wx = win.x; let wy = win.y; let ww = win.w; let ix = wx.max(rx); let iy = wy.max(ry); let ix2 = (wx + ww).min(rx + rw); let iy2 = (wy + outer_h).min(ry + rh); if ix >= ix2 || iy >= iy2 { return; } if win.chrome { // Title bar strip let tb0 = wy; let tb1 = wy + TITLE_BAR_H; if iy < tb1 && iy2 > tb0 { draw_title_bar(id); } // Content strip let c0 = wy + TITLE_BAR_H; let c1 = wy + TITLE_BAR_H + win.h; if iy2 > c0 && iy < c1 { let cy0 = iy.max(c0); let cy1 = iy2.min(c1); let sx = ix - wx; let sy = cy0 - c0; let cw = ix2 - ix; let chh = cy1 - cy0; let src = unsafe { win.buf.add(sy * win.w + sx) }; gop::shadow_blit(ix, cy0, cw, chh, src, win.w); } } else { // Chrome-less: no title bar, buffer is full-window let sx = ix - wx; let sy = iy - wy; let cw = ix2 - ix; let chh = iy2 - iy; let src = unsafe { win.buf.add(sy * win.w + sx) }; gop::shadow_blit(ix, iy, cw, chh, src, win.w); } } /// Draw a window's full title bar (background, title text, min/close). fn draw_title_bar(id: usize) { let win = unsafe { WINDOWS[id] }; let bg = if focused() == Some(id) { TITLE_BG_FOCUS } else { TITLE_BG }; let bgc = gop::rgb(bg.0, bg.1, bg.2); gop::shadow_fill_rect(win.x, win.y, win.w, TITLE_BAR_H, bgc); // Title text, clipped short of the two buttons. let btn_w = 16; let cols = win.w.saturating_sub(btn_w + 4) / 8; for (i, &b) in win.title[..win.title_len].iter().enumerate().take(cols) { gop::shadow_glyph_px(win.x + 4 + i * 8, win.y, b as u16, TITLE_FG, bg); } // Close button (right edge, red) and minimize button (grey). let close_x = win.x + win.w - 8; let min_x = win.x + win.w - 16; gop::shadow_fill_rect( min_x, win.y, 8, TITLE_BAR_H, gop::rgb(MIN_BG.0, MIN_BG.1, MIN_BG.2), ); gop::shadow_fill_rect( close_x, win.y, 8, TITLE_BAR_H, gop::rgb(CLOSE_BG.0, CLOSE_BG.1, CLOSE_BG.2), ); }