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 --- CONTEXT.md | 52 +++ Cargo.lock | 8 + Cargo.toml | 2 +- docs/adr/0004-windowed-gui-and-compositor.md | 78 ++++ rsdos/src/main.rs | 5 + rsnix/src/cursor.rs | 19 +- rsnix/src/gop.rs | 103 ++++- rsnix/src/lib.rs | 9 +- rsnix/src/ps2.rs | 20 + rsnix/src/sched.rs | 59 ++- rsnix/src/tty.rs | 25 +- rsnix/src/vt.rs | 25 ++ rsnix/src/wm.rs | 592 +++++++++++++++++++++++++++ rspaint/Cargo.toml | 10 + rspaint/src/lib.rs | 122 ++++++ shell/Cargo.toml | 1 + shell/src/lib.rs | 67 ++- 17 files changed, 1152 insertions(+), 45 deletions(-) create mode 100644 docs/adr/0004-windowed-gui-and-compositor.md create mode 100644 rsnix/src/wm.rs create mode 100644 rspaint/Cargo.toml create mode 100644 rspaint/src/lib.rs diff --git a/CONTEXT.md b/CONTEXT.md index 48cb3a4..9fccbe4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -77,6 +77,58 @@ Two-phase model matching output: The framebuffer address stays valid after `ExitBootServices` — the GPU's linear frame buffer is system memory that we own once boot services are gone. +## Window + +A rectangular display surface owned by exactly one task and composited +onto the 800×600 canvas by the window manager. The global console (vt) +becomes the first window, owned by the shell; further windows are spawned +per task (a terminal, a drawing canvas, …). Windows may overlap, so they +carry a z-order. Every window is a pixel surface: a terminal window is a +text grid rendered into its own pixels, a drawing canvas draws pixels +directly. The ruling constraint is "stupid simple". + +## Chrome + +Window-manager-owned UI drawn on the canvas but not a window: the +background/desktop fill, per-window title bars, and (later) a +launcher/menu. Chrome is not focusable and does not take input as a +window. + +## Title bar + +Per-window chrome strip at the top of a window showing the window's title +and its minimize and close buttons. Drawn by the window manager, not the +app. + +## Minimized + +A window state in which the window is not rendered (hidden); the app keeps +running. The inverse of the normal visible state. + +## Close + +The action that destroys a window and ends its app. + +## App + +A task that owns exactly one window and receives input events through its +message queue. A GUI program is an app; there is no separate "process" +concept — the app is its task plus its window. The console is app 0 +(window 0). + +## Focus + +The single window (if any) that receives keyboard and mouse input. Focus +is set by clicking a window, which also raises it; input is delivered to +the focused app's message queue. With no focused window, a click only +sets focus. + +## Keybinding + +A global keyboard shortcut handled by the window manager before input is +routed to the focused window. It triggers a WM action (spawn a terminal, +focus/restore the next window, close, …) and is never delivered to an app. + ## Keyboard layout Scancode set 1, US QWERTY layout. Hardcoded scancode→ASCII table. diff --git a/Cargo.lock b/Cargo.lock index 515158d..76b5a2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,10 +21,18 @@ dependencies = [ name = "rsnix" version = "0.5.10" +[[package]] +name = "rspaint" +version = "0.1.0" +dependencies = [ + "rsnix", +] + [[package]] name = "shell" version = "0.5.10" dependencies = [ "rsdosedit", "rsnix", + "rspaint", ] diff --git a/Cargo.toml b/Cargo.toml index 704d86c..90d28c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["rsnix", "shell", "rsdos", "rsdosedit"] +members = ["rsnix", "shell", "rsdos", "rsdosedit", "rspaint"] [workspace.package] version = "0.5.10" diff --git a/docs/adr/0004-windowed-gui-and-compositor.md b/docs/adr/0004-windowed-gui-and-compositor.md new file mode 100644 index 0000000..86e8b30 --- /dev/null +++ b/docs/adr/0004-windowed-gui-and-compositor.md @@ -0,0 +1,78 @@ +# ADR 0004 — Overlapping-window GUI with damage-rectangle compositing and a shared-facility window manager + +## Status + +Accepted. + +## Context + +The kernel's only display surface was a single global text terminal (`vt`), +shared by the shell, klog, and fault dumps. We want a "proper GUI" with +programs (a terminal, a drawing program, …). The kernel is +TempleOS-inspired: one identity-mapped address space, no userspace +separation, preemptive round-robin tasks. The display must stay viable on +real hardware (uncached GOP framebuffers), not just fast on QEMU. The +existing cursor task already routed mouse events to a single "forward +target" in anticipation of a window manager. + +## Decision + +Introduce an overlapping-window GUI: + +- **Window** is the fundamental unit: a rectangular, task-owned **pixel + surface** composited onto the fixed 800×600 canvas, with z-order. +- **Every window is a raster surface.** A terminal window renders its text + grid into its own pixels; a drawing program draws pixels directly. One + content model. +- **Damage-rectangle compositing** (not full-frame redraw) to stay + real-hardware-viable. Damage granularity is **window-level first** + (repaint a dirty window's whole bounds) — revisit sub-window rectangles + if the console proves slow. +- **Repaint** = blit the dirty window, then re-overlay any occluders above + it (rectangle intersection, not subtraction). +- **The window manager is a shared facility, not a task** — in a single + address space there is no isolation to enforce, so a dedicated task only + adds round-trips. +- **App = a task owning one window** (no separate process concept). The + console is app 0. +- **Focus = click-to-focus + raise**; keyboard and mouse go to the focused + window. +- **The console is the launcher**; no taskbar/menu in the first pass. +- **Chrome** (background, per-window title bars, later a menu) is + window-manager-drawn, not windows. A title bar is a strip above a + **content-only** app buffer, with title, minimize, and close. +- **Restore** of minimized windows is via a console command + (`windows`/`restore`) plus global **keybindings** F1–F4 (spawn terminal, + focus next, minimize, close); F1–F4 are reserved and invisible to apps. +- **Window buffers** are dedicated `w×h×4` buffers allocated from the page + allocator (the same mechanism as the shadow backbuffer); full-screen + (800×600) content is allowed. +- **Drawing is explicit** — the app writes into its buffer and calls + `flush()` to request a composite; there is no automatic per-tick + compositing. +- **App event contract** — an app's queue delivers `Key`, `Mouse { x, y, + buttons, kind }` (absolute window-relative; `kind` = Move/Press/Release), + `Focus(bool)`, `Close`, `Timer`, and `User(u64)`. Mouse events outside + the focused window's content bounds are suppressed. +- **Minimum window size** is 64×64 content. +- **Window lifecycle** — a window is created by and bound to its task: it + is freed (buffer returned) when the task exits, via a reap hook. Close + is cooperative: `Close` is delivered and the app exits; three F4 presses + in quick succession force-teardown (free the window, kill the task). The + console (app 0) never closes. +- **Placement** — new windows cascade (+24px each, wrapping) and are + focused and raised on creation; the console is full-canvas at (0,0). + Windows are fixed-position and fixed-size in v1: move is the first + post-v1 feature and resize is deferred. + +## Consequences + +- Positive: one content model, one compositing path, minimal concepts. +- Negative: window-level damage means a full-screen console repaints its + whole rectangle per keystroke — accepted for now, revisitable. +- Negative: windows are fixed-size initially (no resize). +- The existing `vt` dirty-cell path is demoted to a per-terminal-window + trigger; the window manager becomes the only writer of the framebuffer + (plus the cursor overlay). +- Open (not yet decided): the terminal-app design (per-instance vt + shell), + and the save/image-format + image-viewer design (both deferred). diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs index 0e159e6..89df5ea 100644 --- a/rsdos/src/main.rs +++ b/rsdos/src/main.rs @@ -418,6 +418,11 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut // later, and prints the exact field values when it is. rsnix::vt::debug_verify(); + // Windowed GUI: initialise the window manager and its input pump. + // The console (vt) stays layer 0; GUI apps are windows composited on top. + rsnix::wm::init(); + rsnix::wm::spawn_input_pump(); + shell::run(); } diff --git a/rsnix/src/cursor.rs b/rsnix/src/cursor.rs index 31b919e..b986b23 100644 --- a/rsnix/src/cursor.rs +++ b/rsnix/src/cursor.rs @@ -49,14 +49,29 @@ fn forward_target() -> Option { } fn task_main() { + let mut prev_buttons: u8 = 0; loop { while let Some(ev) = crate::usb::poll_mouse() .or_else(crate::ehci::poll_mouse) .or_else(crate::uhci::poll_mouse) .or_else(crate::ps2::try_poll_mouse) { - crate::gop::cursor_move(ev.dx, ev.dy); - crate::gop::cursor_set_buttons(btn_bits(&ev)); + let (cx, cy) = crate::gop::cursor_move(ev.dx, ev.dy); + let buttons = btn_bits(&ev); + crate::gop::cursor_set_buttons(buttons); + // Left-button edge detection → press/release. + let kind = if buttons & 1 != 0 && prev_buttons & 1 == 0 { + crate::ps2::MouseKind::Press + } else if buttons & 1 == 0 && prev_buttons & 1 != 0 { + crate::ps2::MouseKind::Release + } else { + crate::ps2::MouseKind::Move + }; + prev_buttons = buttons; + // Route to the window manager (focus + deliver to the focused + // window with window-relative coordinates). + crate::wm::route_mouse(cx as i32, cy as i32, buttons, kind); + // Legacy single-target debug routing (`mouse `). if let Some(id) = forward_target() && crate::sched::send(id, TaskMsg::Mouse(ev)).is_err() { diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index 676814e..deff226 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -4,10 +4,10 @@ /// module is the low-level rasteriser: glyph blits and pixel shifts on the /// linear framebuffer. use crate::font; -use crate::uefi::{GopModeInfo, GraphicsOutputProtocol, EFI_SUCCESS, PIXEL_BGR, PIXEL_RGB}; +use crate::uefi::{EFI_SUCCESS, GopModeInfo, GraphicsOutputProtocol, PIXEL_BGR, PIXEL_RGB}; use core::fmt; use core::ptr; -use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering}; /// Framebuffer state captured before ExitBootServices. pub struct Framebuffer { @@ -512,6 +512,105 @@ pub fn fill_rect(x: usize, y: usize, w: usize, h: usize, color: u32) { push_rect(x, y, w, h); } +/// Fill a shadow rectangle WITHOUT pushing to the display. The window +/// manager composites several surfaces into the shadow and pushes once at +/// the end. +pub(crate) fn shadow_fill_rect(x: usize, y: usize, w: usize, h: usize, color: u32) { + let Some(fb) = fb().as_ref() else { + return; + }; + if unsafe { SHADOW.is_null() } || x >= fb.width || y >= fb.height { + return; + } + let w = w.min(fb.width - x); + let h = h.min(fb.height - y); + let row_words = fb.pitch / 4; + unsafe { + for dy in 0..h { + let off = (y + dy) * row_words + x; + for dx in 0..w { + *SHADOW.add(off + dx) = color; + } + } + } +} + +/// Copy a row-major `u32` source buffer (stride in pixels) into the shadow +/// WITHOUT pushing. Clipped to the framebuffer. +pub(crate) fn shadow_blit( + x: usize, + y: usize, + w: usize, + h: usize, + src: *const u32, + src_stride: usize, +) { + let Some(fb) = fb().as_ref() else { + return; + }; + if unsafe { SHADOW.is_null() } || src.is_null() || x >= fb.width || y >= fb.height { + return; + } + let w = w.min(fb.width - x); + let h = h.min(fb.height - y); + let row_words = fb.pitch / 4; + unsafe { + for dy in 0..h { + let doff = (y + dy) * row_words + x; + let soff = dy * src_stride; + for dx in 0..w { + *SHADOW.add(doff + dx) = *src.add(soff + dx); + } + } + } +} + +/// Render one 8×16 glyph into the shadow at a pixel origin WITHOUT pushing +/// (for window chrome and console repaint). Same glyph lookup as +/// `blit_glyph`, but caller-controlled push. +pub(crate) fn shadow_glyph_px(x: usize, y: usize, ch: u16, fg: (u8, u8, u8), bg: (u8, u8, u8)) { + let Some(fb) = fb().as_ref() else { + return; + }; + if unsafe { SHADOW.is_null() } { + return; + } + let glyph = font::GLYPH_8X16; + let data = if ch < 256 { + match glyph.get(ch as usize) { + Some(d) => *d, + None => return, + } + } else if let Some(bits) = unicode_glyph_bits(ch) { + bits + } else { + match glyph.get(32) { + Some(d) => *d, + None => return, + } + }; + let fgc = rgb(fg.0, fg.1, fg.2); + let bgc = rgb(bg.0, bg.1, bg.2); + let row_words = fb.pitch / 4; + unsafe { + for cy in 0..16 { + let py = y + cy; + if py >= fb.height { + break; + } + let row_bits = data[cy]; + for cx in 0..8 { + let px = x + cx; + if px >= fb.width { + break; + } + let on = row_bits & (0x80 >> cx) != 0; + *SHADOW.add(py * row_words + px) = if on { fgc } else { bgc }; + } + } + } +} + /// Print crash/fault lines directly to the framebuffer, bypassing the vt /// entirely (the vt state may be corrupt when an exception fires). /// diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs index f5c3419..a422057 100644 --- a/rsnix/src/lib.rs +++ b/rsnix/src/lib.rs @@ -4,6 +4,7 @@ extern crate alloc; pub mod ahci; pub mod cursor; +pub mod ehci; pub mod fat; pub mod font; pub mod fs; @@ -14,16 +15,16 @@ pub mod klog; pub mod memory; pub mod paging; pub mod pci; -pub mod qr; pub mod power; pub mod ps2; -pub mod uhci; -pub mod ehci; -pub mod usb; +pub mod qr; pub mod rtc; pub mod sched; pub mod serial; pub mod strbuf; pub mod tty; pub mod uefi; +pub mod uhci; +pub mod usb; pub mod vt; +pub mod wm; diff --git a/rsnix/src/ps2.rs b/rsnix/src/ps2.rs index 965e4a0..cac476f 100644 --- a/rsnix/src/ps2.rs +++ b/rsnix/src/ps2.rs @@ -463,6 +463,26 @@ pub struct MouseEvent { pub middle: bool, } +/// What happened in a window-relative (GUI) mouse event. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseKind { + Move, + Press, + Release, +} + +/// A mouse event delivered to a focused GUI window: absolute coordinates +/// relative to the window's content top-left, the current button bitmask, +/// and the action (move / press / release). Unlike `MouseEvent` (relative +/// deltas), this is what an app's drawing loop consumes. +#[derive(Clone, Copy, Debug)] +pub struct AppMouseEvent { + pub x: i32, + pub y: i32, + pub buttons: u8, + pub kind: MouseKind, +} + /// Packet assembly state — lives in main context (consumer side), fed by /// the raw byte ring that the IRQ12 handler fills. struct MouseState { diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs index 259ddf6..39ecb65 100644 --- a/rsnix/src/sched.rs +++ b/rsnix/src/sched.rs @@ -59,6 +59,9 @@ const MSG_CAP: usize = 16; pub enum TaskMsg { Key(crate::ps2::KeyEvent), Mouse(crate::ps2::MouseEvent), + AppMouse(crate::ps2::AppMouseEvent), + Focus(bool), + Close, Timer, User(u64), } @@ -94,6 +97,11 @@ pub fn msg_describe(msg: &TaskMsg) -> alloc::string::String { if m.left { "L" } else { "-" }, if m.right { "R" } else { "-" } ), + TaskMsg::AppMouse(m) => { + alloc::format!("AppMouse({},{} {:?} b={:#x})", m.x, m.y, m.kind, m.buttons) + } + TaskMsg::Focus(f) => alloc::format!("Focus({})", f), + TaskMsg::Close => "Close".into(), TaskMsg::Timer => "Timer".into(), TaskMsg::User(n) => alloc::format!("User({})", n), } @@ -179,7 +187,10 @@ struct SpinWatch { last_rsp: usize, same: u32, } -const SPIN_WATCH_INIT: SpinWatch = SpinWatch { last_rsp: 0, same: 0 }; +const SPIN_WATCH_INIT: SpinWatch = SpinWatch { + last_rsp: 0, + same: 0, +}; static mut SPIN_WATCH: [SpinWatch; MAX_TASKS] = [SPIN_WATCH_INIT; MAX_TASKS]; /// A spinning background task is interrupted at roughly half the tick rate /// (it alternates with idle), so 1500 hits is ~30 s of wall time. @@ -381,11 +392,7 @@ pub fn spawn(entry: fn(), name: &str) -> Option { }; let ok = spawn_at(id, entry, name); unlock(); - if ok { - Some(id) - } else { - None - } + if ok { Some(id) } else { None } } unsafe fn reap(id: usize) { @@ -698,10 +705,12 @@ pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFra } let ra = *(fp as *const u64).add(1); // [rbp+8] = return address if let Some((start, end)) = filter - && ra >= start as u64 && ra < end as u64 { - bt[bn] = ra; - bn += 1; - } + && ra >= start as u64 + && ra < end as u64 + { + bt[bn] = ra; + bn += 1; + } let nfp = *(fp as *const u64); if nfp <= fp { break; // chain must grow upward @@ -715,19 +724,21 @@ pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFra while p + 8 <= limit && bn < bt.len() { let v = *(p as *const u64); if let Some((start, end)) = filter - && v >= start as u64 && v < end as u64 { - let mut dup = false; - for i in 0..bn { - if bt[i] == v { - dup = true; - break; - } - } - if !dup { - bt[bn] = v; - bn += 1; + && v >= start as u64 + && v < end as u64 + { + let mut dup = false; + for i in 0..bn { + if bt[i] == v { + dup = true; + break; } } + if !dup { + bt[bn] = v; + bn += 1; + } + } p += 8; } } @@ -842,7 +853,11 @@ pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFra &mut w, core::format_args!( "rax={:#x} rcx={:#x} rdx={:#x} rsi={:#x} rdi={:#x}", - frame.rax, frame.rcx, frame.rdx, frame.rsi, frame.rdi + frame.rax, + frame.rcx, + frame.rdx, + frame.rsi, + frame.rdi ), ); w.len() diff --git a/rsnix/src/tty.rs b/rsnix/src/tty.rs index 0783521..6102fb5 100644 --- a/rsnix/src/tty.rs +++ b/rsnix/src/tty.rs @@ -41,14 +41,31 @@ static mut RAW_LEN: usize = 0; /// Combined blocking key poll: USB HID keyboard first (polled UHCI), then /// the PS/2 path (which parks via `hlt` until an IRQ-fed scancode lands). fn poll_key() -> KeyEvent { - if let Some(ev) = crate::uhci::try_poll_key() { - return ev; + loop { + // A GUI window has focus: the keyboard pump owns the keys. Park so + // the shell/editor don't steal input from the focused app. + if crate::wm::focused().is_some() { + crate::sched::sleep_ms(5); + continue; + } + if let Some(ev) = crate::uhci::try_poll_key() { + return ev; + } + if let Some(ev) = ps2::try_poll_key() { + return ev; + } + // No key ready: park briefly, then re-check focus (so a focus change + // to a GUI window is noticed instead of blocking on a stolen key). + crate::sched::sleep_ms(5); } - ps2::poll_key() } -/// Combined non-blocking key poll (USB first, then PS/2). +/// Combined non-blocking key poll (USB first, then PS/2). Returns `None` +/// while a GUI window is focused (the keyboard pump owns the keys). fn try_poll_key() -> Option { + if crate::wm::focused().is_some() { + return None; + } crate::uhci::try_poll_key().or_else(ps2::try_poll_key) } diff --git a/rsnix/src/vt.rs b/rsnix/src/vt.rs index 9444063..c5fc5fd 100644 --- a/rsnix/src/vt.rs +++ b/rsnix/src/vt.rs @@ -147,6 +147,31 @@ pub fn size() -> (usize, usize) { unsafe { (ROWS, COLS) } } +/// Re-render the console's cells into the shadow for a pixel region, so the +/// window manager can restore the console (layer 0) beneath a window that +/// moved, closed, or was minimized. Does not push — the caller pushes once. +pub fn repaint_region(x: usize, y: usize, w: usize, h: usize) { + unsafe { + if CELLS.is_null() { + return; + } + let (cols, rows) = (COLS, ROWS); + let c0 = x / 8; + let c1 = (x + w).div_ceil(8).min(cols); + let r0 = y / 16; + let r1 = (y + h).div_ceil(16).min(rows); + let cells = &*CELLS; + for r in r0..r1 { + for c in c0..c1 { + let cell = cells[r * cols + c]; + let fg = PALETTE[cell.fg as usize]; + let bg = PALETTE[cell.bg as usize]; + crate::gop::shadow_glyph_px(c * 8, r * 16, cell.ch, fg, bg); + } + } + } +} + /// DEBUG (crash-family): read one cell's raw word (ch | fg<<16 | bg<<24) /// from the live cells grid and the rendered grid, for the given text /// row/col. Returns (raw_cell, raw_rendered). 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); + } +} diff --git a/rspaint/Cargo.toml b/rspaint/Cargo.toml new file mode 100644 index 0000000..66364a2 --- /dev/null +++ b/rspaint/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rspaint" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +rsnix = { path = "../rsnix" } diff --git a/rspaint/src/lib.rs b/rspaint/src/lib.rs new file mode 100644 index 0000000..7c2e2d2 --- /dev/null +++ b/rspaint/src/lib.rs @@ -0,0 +1,122 @@ +#![no_std] + +//! Paint — the first GUI app: a freehand pen on a raster canvas. + +use rsnix::ps2::{KeyEvent, MouseKind}; +use rsnix::{sched, wm}; + +const W: usize = 640; +const H: usize = 480; + +/// 8-colour palette selected by keys 1–8. +const PALETTE: [(u8, u8, u8); 8] = [ + (255, 255, 255), // 1 white + (0, 0, 0), // 2 black + (170, 0, 0), // 3 red + (0, 170, 0), // 4 green + (0, 0, 170), // 5 blue + (170, 170, 0), // 6 yellow + (0, 170, 170), // 7 cyan + (170, 0, 170), // 8 magenta +]; + +/// Run the paint program as a spawned task: create a window, then loop on +/// events (pen strokes from the mouse, colour/clear from keys) until `Close`. +pub fn run() { + let Some(id) = wm::create_window("paint", W, H) else { + return; + }; + let Some((buf, _, _)) = wm::buffer(id) else { + return; + }; + + let mut color: usize = 0; // white + let mut last: Option<(i32, i32)> = None; + + loop { + match sched::recv_blocking() { + sched::TaskMsg::Close => break, + sched::TaskMsg::AppMouse(m) => match m.kind { + MouseKind::Press => { + plot(buf, m.x, m.y, color); + last = Some((m.x, m.y)); + wm::flush(id); + } + MouseKind::Move => { + if m.buttons & 1 != 0 { + if let Some((lx, ly)) = last { + line(buf, lx, ly, m.x, m.y, color); + wm::flush(id); + } + } + last = Some((m.x, m.y)); + } + MouseKind::Release => { + last = None; + } + }, + sched::TaskMsg::Key(k) => { + if let KeyEvent::Char(c) = k { + match c { + '1'..='8' => color = (c as u8 - b'1') as usize, + 'c' => { + fill(buf, 1); // clear to black + wm::flush(id); + } + _ => {} + } + } + } + _ => {} + } + } + wm::destroy(id); +} + +fn pack(idx: usize) -> u32 { + let (r, g, b) = PALETTE[idx.min(PALETTE.len() - 1)]; + rsnix::gop::rgb(r, g, b) +} + +fn plot(buf: *mut u32, x: i32, y: i32, color: usize) { + if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { + return; + } + unsafe { + *buf.add(y as usize * W + x as usize) = pack(color); + } +} + +/// Bresenham line between two points. +fn line(buf: *mut u32, x0: i32, y0: i32, x1: i32, y1: i32, color: usize) { + let mut x = x0; + let mut y = y0; + let dx = (x1 - x0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let dy = -(y1 - y0).abs(); + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + plot(buf, x, y, color); + if x == x1 && y == y1 { + break; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x += sx; + } + if e2 <= dx { + err += dx; + y += sy; + } + } +} + +fn fill(buf: *mut u32, color: usize) { + unsafe { + for i in 0..(W * H) { + *buf.add(i) = pack(color); + } + } +} diff --git a/shell/Cargo.toml b/shell/Cargo.toml index bc35524..f95b574 100644 --- a/shell/Cargo.toml +++ b/shell/Cargo.toml @@ -9,3 +9,4 @@ workspace = true [dependencies] rsnix = { path = "../rsnix" } rsdosedit = { path = "../rsdosedit" } +rspaint = { path = "../rspaint" } diff --git a/shell/src/lib.rs b/shell/src/lib.rs index bb048d8..f1addd9 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -53,14 +53,15 @@ pub fn run() -> ! { term_println("!!! PREVIOUS CRASH REPORT:"); let mut buf = [0u8; 256]; if let Ok(n) = rsnix::fs::read(h, &mut buf, 0) - && n > 0 { - let s = core::str::from_utf8(&buf[..n]).unwrap_or("?"); - for line in s.split('\n') { - if !line.is_empty() { - term_println(line); - } + && n > 0 + { + let s = core::str::from_utf8(&buf[..n]).unwrap_or("?"); + for line in s.split('\n') { + if !line.is_empty() { + term_println(line); } } + } term_println("!!! end of crash report"); } @@ -739,6 +740,9 @@ write `{} TIL `", "touch" => cmd_touch(&parsed), "rm" => cmd_rm(&parsed), "edit" => cmd_edit(&parsed), + "draw" => cmd_draw(&parsed), + "windows" => cmd_windows(&parsed), + "restore" => cmd_restore(&parsed), "bg" => cmd_bg(&parsed), "jobs" => cmd_jobs(&parsed), "msgrecv" => cmd_msgrecv(&parsed), @@ -1387,6 +1391,40 @@ fn cmd_edit(p: &Parsed) { rsnix::gop::clear_screen(); } +fn cmd_draw(_p: &Parsed) { + match rsnix::sched::spawn(rspaint::run, "paint") { + Some(id) => term_println(&alloc::format!( + "draw: paint task {} — F4 or the close button to close", + id + )), + None => term_err("draw: task table full"), + } +} + +fn cmd_windows(_p: &Parsed) { + let list = rsnix::wm::list(); + if list.is_empty() { + term_println("windows: none (console only)"); + return; + } + for (id, title, minimized) in list { + term_println(&alloc::format!( + " {} {}{}", + id, + title, + if minimized { " (minimized)" } else { "" } + )); + } +} + +fn cmd_restore(p: &Parsed) { + let Some(id) = p.args.first().and_then(|s| s.parse::().ok()) else { + term_err("usage: restore "); + return; + }; + rsnix::wm::restore(id); +} + fn cmd_mem(p: &Parsed) { let (map, key) = rsnix::memory::get_map(); @@ -1602,14 +1640,19 @@ fn cmd_heapdbg(_p: &Parsed) { let ov = h.heap_start < fb.saturating_add(fsz) && fb < h.heap_end; term_println(&alloc::format!( "fb {:#x} size={:#x} overlap-heap={}", - fb, fsz, ov + fb, + fsz, + ov )); } let probe = h.heap_start.wrapping_add(0x443e0); // historical crash offset match rsnix::memory::freelist_contains(probe) { Some((b, s, off)) => term_println(&alloc::format!( "probe {:#x} (+0x443e0) IN free blk {:#x} size={:#x} off={:#x}", - probe, b, s, off + probe, + b, + s, + off )), None => term_println(&alloc::format!("probe {:#x} (+0x443e0) NOT free", probe)), } @@ -1682,7 +1725,9 @@ fn cmd_heapdbg(_p: &Parsed) { let (rc, rr) = rsnix::vt::cell_raw(tr, tc); term_println(&alloc::format!( "shadow@{:x} cell={:08x} rendered={:08x}", - f, rc, rr + f, + rc, + rr )); } } @@ -1697,7 +1742,9 @@ fn cmd_heapdbg(_p: &Parsed) { match rsnix::memory::freelist_contains(b) { Some((fb, fs, fo)) => term_println(&alloc::format!( "shadow base IN free list: blk {:#x} size={:#x} off={:#x}", - fb, fs, fo + fb, + fs, + fo )), None => term_println("shadow base NOT in free list"), } -- cgit v1.2.3