//! 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 console (`vt`) is layer 0 and is re-rendered (never //! re-allocated) when a region is exposed; GUI windows are composited //! above it. Chrome (title bar) is WM-drawn; each window's buffer is its //! content only. //! //! Apps call in directly (`create_window`, `flush`, …). Input routing and //! keybindings are handled here so `handle_key` is the single entry point //! for the GUI keyboard path. extern crate alloc; 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); #[derive(Clone, Copy, PartialEq, Eq)] enum WinState { Visible, Minimized, } #[derive(Clone, Copy)] struct Window { used: bool, state: WinState, title: [u8; TITLE_MAX], title_len: usize, #[allow(dead_code)] // read once input routing delivers to the owner's queue owner: usize, x: usize, y: usize, w: usize, h: usize, buf: *mut u32, } 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(), }; static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY; MAX_WINDOWS]; /// Window ids in bottom→top z-order. 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; /// Initialise window state. The console (vt) remains the visible layer 0; /// no GUI windows exist yet. pub fn init() { unsafe { ZLEN = 0; CASCADE_X = 0; CASCADE_Y = 0; } FOCUSED.store(CONSOLE, Ordering::Relaxed); // When the console scrolls, re-composite windows so they stay put. gop::set_scroll_hook(redraw_windows); } /// The focused window, or `None` if the console has focus. pub fn focused() -> Option { let v = FOCUSED.load(Ordering::Relaxed); if v == CONSOLE { 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 window owned by the current task. `w`×`h` is the *content* /// size (title bar excluded). Returns the window id, or `None` on a full /// table or an allocation failure. 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)?; // Zero-fill so a fresh canvas reads black, not garbage. 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, }; ZORDER[ZLEN] = id; ZLEN += 1; } focus(id); Some(id) } /// Composite a window's full outer bounds (bottom→top), restoring the /// console beneath it. Called by an app after it writes into its buffer. pub fn flush(id: usize) { let Some(win) = get(id) else { return; }; composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); } /// Destroy a window: free its buffer, remove it, restore the exposed region. pub fn destroy(id: usize) { let Some(win) = get(id) else { return; }; let (x, y, w, h) = (win.x, win.y, win.w, win.h + TITLE_BAR_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); } /// Minimize a window: it is no longer rendered (the app keeps running). pub fn minimize(id: usize) { let Some(win) = get(id) else { return; }; if win.state == WinState::Minimized { return; } let (x, y, w, h) = (win.x, win.y, win.w, win.h + TITLE_BAR_H); unsafe { WINDOWS[id].state = WinState::Minimized; } remove_from_zorder(id); if focused() == Some(id) { focus_console(); } composite_rect(x, y, w, h); } /// Restore a minimized window (back to the top, focused). 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); composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); } /// Focus a window (raising it). Repaints the newly-focused window (so it /// appears above former occluders) and un-focuses the previous title bar. pub fn focus(id: usize) { let Some(win) = get(id) else { return; }; if win.state != WinState::Visible { return; } // Raise to the top of the z-order. remove_from_zorder(id); unsafe { ZORDER[ZLEN] = id; ZLEN += 1; } let prev = focused(); FOCUSED.store(id, Ordering::Relaxed); if let Some(p) = prev && p != id && let Some(pw) = get(p) { let _ = crate::sched::send(pw.owner, TaskMsg::Focus(false)); composite_rect(pw.x, pw.y, pw.w, TITLE_BAR_H); } let _ = crate::sched::send(win.owner, TaskMsg::Focus(true)); composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); } /// Move focus to the console. 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)); composite_rect(pw.x, pw.y, pw.w, TITLE_BAR_H); } } /// Cycle focus: console → top window → … → bottom window → console. 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]); } } } } } /// Handle a global WM keybinding. Returns `true` if the key was consumed /// (F1–F4), `false` if it should be delivered to the focused app. 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, } } fn spawn_terminal() { // Terminal app deferred (needs per-instance vt + per-instance shell). crate::serial_println!("wm: F1 (spawn terminal) not implemented yet"); } 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; } } /// Repaint a canvas region bottom→top: restore the console (layer 0), then /// blit every visible window that intersects, then push once. fn composite_rect(x: usize, y: usize, w: usize, h: usize) { crate::vt::repaint_region(x, y, w, h); 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)`. 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 { return; } let wx = win.x; let wy = win.y; let ww = win.w; let whh = win.h + TITLE_BAR_H; let ix = wx.max(rx); let iy = wy.max(ry); let ix2 = (wx + ww).min(rx + rw); let iy2 = (wy + whh).min(ry + rh); if ix >= ix2 || iy >= iy2 { return; } // 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); } } /// 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), ); } /// The task that owns a window (its input destination). pub fn owner(id: usize) -> Option { get(id).map(|w| w.owner) } /// A window's content buffer (pointer, width, height) for direct drawing. pub fn buffer(id: usize) -> Option<(*mut u32, usize, usize)> { get(id).map(|w| (w.buf, w.w, w.h)) } /// List windows as `(id, title, minimized)` for the shell `windows` command. 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 { 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 } /// Request a cooperative close: deliver `Close` to the owning app (which is /// expected to call `destroy` and exit). pub fn request_close(id: usize) { if let Some(w) = get(id) { let _ = crate::sched::send(w.owner, TaskMsg::Close); } } #[derive(Clone, Copy, PartialEq, Eq)] enum Hit { None, Close, Minimize, Title, Content, } /// Topmost visible window whose *outer* bounds contain the point. 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; } if x >= w.x && x < w.x + w.w && y >= w.y && y < w.y + w.h + TITLE_BAR_H { return Some(id); } } } None } fn hit_test(id: usize, x: usize, y: usize) -> Hit { let Some(w) = get(id) else { return Hit::None; }; 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 } /// What an in-progress mouse gesture is doing (started on a press). #[derive(Clone, Copy, PartialEq, Eq)] enum Drag { None, Move, // dragging a window by its title bar App, // a stroke/drag inside a window's content } static mut DRAG: Drag = Drag::None; static mut DRAG_ID: usize = usize::MAX; static mut DRAG_DX: i32 = 0; // cursor - window.x at drag start static mut DRAG_DY: i32 = 0; // cursor - window.y at drag start /// Route one mouse event (absolute canvas coords, button bitmask, kind). 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 rx = cx as i32 - w.x as i32; let ry = cy as i32 - (w.y + TITLE_BAR_H) as i32; // Suppress out-of-bounds Press/Move, but always deliver Release so a // stroke that ends outside the window still terminates. 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)); } /// Move a window to (nx, ny), clamping to the canvas, and repaint old+new. fn move_window(id: usize, nx: usize, ny: usize) { 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; } composite_rect(ox, oy, w.w, w.h + TITLE_BAR_H); composite_rect(nx, ny, w.w, w.h + TITLE_BAR_H); } /// Spawn the keyboard pump task (routes keys to the focused GUI window). pub fn spawn_input_pump() -> Option { crate::sched::spawn(pump_main, "wm-input") } /// Re-composite every visible window. Called after the console scrolls so /// windows stay in place while the text layer beneath them shifts. pub fn redraw_windows() { unsafe { for i in 0..ZLEN { let id = ZORDER[i]; let w = WINDOWS[id]; if w.used && w.state == WinState::Visible { composite_rect(w.x, w.y, w.w, w.h + TITLE_BAR_H); } } } } fn pump_main() { loop { if focused().is_some() { while let Some(ev) = crate::ps2::try_poll_key().or_else(crate::uhci::try_poll_key) { if !handle_key(ev) && let Some(id) = focused() && let Some(o) = owner(id) { let _ = crate::sched::send(o, TaskMsg::Key(ev)); } } } crate::sched::sleep_ms(5); } }