From 5558e3509d020a0e021a77f0617044497b2f2aee Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 17 Aug 2026 18:56:04 +0200 Subject: Windowed GUI: wm module, input routing, and the paint app - Add the wm window-manager facility per ADR 0004: overlapping pixel-surface windows, damage-rectangle compositing, WM-drawn title bar, focus/minimize/close, and F1-F4 keybindings - Add gop shadow compositing primitives and vt::repaint_region so the console stays layer 0 beneath windows - Route mouse (absolute coords, press/release edges) and keyboard (focus-aware input pump) to the focused window - Add the rspaint drawing-program crate (pen, 8 colors, clear) and shell draw/windows/restore commands - Extend TaskMsg with AppMouse/Focus/Close and document the design in CONTEXT.md + ADR 0004 --- rsnix/src/wm.rs | 592 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 592 insertions(+) create mode 100644 rsnix/src/wm.rs (limited to 'rsnix/src/wm.rs') diff --git a/rsnix/src/wm.rs b/rsnix/src/wm.rs new file mode 100644 index 0000000..a39702d --- /dev/null +++ b/rsnix/src/wm.rs @@ -0,0 +1,592 @@ +//! 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); +} + +/// 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 +} + +/// Route one mouse event (absolute canvas coords, button bitmask, kind). +pub fn route_mouse(x: i32, y: i32, buttons: u8, kind: MouseKind) { + if kind == MouseKind::Press { + 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), + Hit::Content => { + focus(id); + deliver_mouse(id, cx, cy, buttons, kind); + } + Hit::None => {} + } + } else { + focus_console(); + } + } else if let Some(id) = focused() { + deliver_mouse(id, x as usize, y as usize, buttons, kind); + } +} + +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; + if rx < 0 || ry < 0 || rx >= w.w as i32 || ry >= w.h as i32 { + return; // out of bounds — suppressed + } + let ev = AppMouseEvent { + x: rx, + y: ry, + buttons, + kind, + }; + let _ = crate::sched::send(w.owner, TaskMsg::AppMouse(ev)); +} + +/// 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") +} + +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); + } +} -- cgit v1.2.3