From 819778f292ee4e1294603b98662e083e49e23463 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 1 Aug 2026 19:52:05 +0200 Subject: rsnix v0.5.0: graphics layer + cursor compositor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first visible GUI surface: real rect primitives, a single shadow→display push path, and an on-screen mouse cursor that can never race the text surface. gop: - fill_rect + push_rect(x,y,w,h): every raster op rasterises into the RAM shadow, then push_rect copies the rect to the display (write-only, still never read) and composites the cursor on top. blit_glyph and scroll shifts now route through it. - cursor overlay: 12x16 pointer (const-fn built from column lists) drawn white with a black offset outline; position in one AtomicU64 (x:high 32, y:low 32) — unpack masks the low word (a bare 'as usize' cast is a no-op on 64-bit). The cursor never enters the shadow, so erasing is just a shadow push of the old rect. - cursor_redraw after scroll shifts and once at spawn (the vt diff never re-blits idle cells). cursor task (task 2, spawned at boot when a mouse is present): - owns the PS/2 ring; polls every 10 ms, moves the cursor (clamped), tracks buttons, and forwards every event to a task's queue when 'mouse ' armed it (the window-manager input path). - /dev/mouse is now a position device (x/y/buttons snapshot); 'mouse' watches the cursor live (prints on change); 'cursor' prints one-shot state; 'gfx' draws a status-bar demo; 'gfx check' is an in-kernel pixel self-test (shadow readback, display readback, cursor overlay purity) — PASS/FAIL per stage. Tests (headless QEMU + monitor injection): - make test-gfx (new): all four compositor checks PASS. - make test-mouse (rewritten): cursor tracks mouse_move deltas, buttons ride along, /dev/mouse snapshot, live watch, real-event queue routing. - make test-msg: task numbering shifted (cursor is task 2; msgrecv is task 3) — updated, still PASS. - make test-disk regression re-passes. 0 build warnings. --- rsnix/src/cursor.rs | 71 ++++++++++++++ rsnix/src/fs.rs | 37 +++----- rsnix/src/gop.rs | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++-- rsnix/src/lib.rs | 1 + 4 files changed, 346 insertions(+), 31 deletions(-) create mode 100644 rsnix/src/cursor.rs (limited to 'rsnix') diff --git a/rsnix/src/cursor.rs b/rsnix/src/cursor.rs new file mode 100644 index 0000000..18c9a1d --- /dev/null +++ b/rsnix/src/cursor.rs @@ -0,0 +1,71 @@ +// --------------------------------------------------------------------------- +// cursor — the mouse cursor task. +// +// One background task owns the PS/2 mouse: it polls the packet ring every +// 10 ms, moves the on-screen cursor (gop's display overlay), tracks the +// button state, and optionally *forwards* every event to another task's +// message queue (`mouse ` sets the target) — the input-routing half +// of the future window manager. +// +// The cursor itself never touches the text surface: `gop::push_rect` +// composites it on top of every display write, so text scrolling and +// cursor movement cannot corrupt each other (see gop.rs). +// --------------------------------------------------------------------------- +use core::sync::atomic::{AtomicUsize, Ordering}; + +use crate::ps2::MouseEvent; +use crate::sched::TaskMsg; + +/// Forward target task id, or `usize::MAX` = none. +static FORWARD_TARGET: AtomicUsize = AtomicUsize::new(usize::MAX); + +/// Spawn the cursor task. Call after `sched::init`; only makes sense +/// when the PS/2 mouse came up at boot. The cursor starts centred. +pub fn spawn() -> Option { + let (w, h) = crate::gop::pixel_dims(); + crate::gop::cursor_set(w / 2 - 6, h / 2 - 8); + // Draw it immediately: the vt diff only re-blits changed cells, so an + // idle centre cell would never push (and thus never composite) it. + crate::gop::cursor_redraw(); + let id = crate::sched::spawn(task_main, "cursor")?; + crate::serial_println!( + "cursor: task {} — mouse poll + display overlay (forward target: none)", + id + ); + Some(id) +} + +/// Route decoded mouse events to a task's queue (the shell's `mouse +/// `). `None` stops forwarding. +pub fn set_forward(target: Option) { + FORWARD_TARGET.store(target.unwrap_or(usize::MAX), Ordering::Relaxed); +} + +fn forward_target() -> Option { + match FORWARD_TARGET.load(Ordering::Relaxed) { + usize::MAX => None, + id => Some(id), + } +} + +fn task_main() { + loop { + while let Some(ev) = crate::ps2::try_poll_mouse() { + crate::gop::cursor_move(ev.dx, ev.dy); + crate::gop::cursor_set_buttons(btn_bits(&ev)); + if let Some(id) = forward_target() + && crate::sched::send(id, TaskMsg::Mouse(ev)).is_err() + { + // The listener died mid-stream: stop routing (the + // cursor keeps tracking) rather than spam errors. + set_forward(None); + crate::serial_println!("cursor: forward target task {} gone — stopped routing", id); + } + } + crate::sched::sleep_ms(10); + } +} + +fn btn_bits(ev: &MouseEvent) -> u8 { + (ev.left as u8) | ((ev.right as u8) << 1) | ((ev.middle as u8) << 2) +} diff --git a/rsnix/src/fs.rs b/rsnix/src/fs.rs index 0b7a772..5fc6d7a 100644 --- a/rsnix/src/fs.rs +++ b/rsnix/src/fs.rs @@ -713,34 +713,23 @@ fn dev_text(dev: CharDevice, _pos: u64) -> String { } } -/// Render `/dev/mouse`: drain every pending PS/2 event (up to 32) into one -/// line each, so `cat /dev/mouse` shows the movement that happened since -/// the last read. Falls back to a clear message when there is no device or -/// nothing pending. +/// Render `/dev/mouse`: the current cursor state (position + buttons). +/// The cursor task owns the PS/2 ring, so this is a position device — +/// the GUI-correct semantic — rather than an event drain. fn mouse_text() -> String { if !crate::ps2::mouse_present() { return String::from("mouse: no PS/2 mouse\n"); } - let mut s = String::new(); - for _ in 0..32 { - let Some(ev) = crate::ps2::try_poll_mouse() else { - break; - }; - let mut b = String::new(); - b.push(if ev.left { 'L' } else { '-' }); - b.push(if ev.right { 'R' } else { '-' }); - b.push(if ev.middle { 'M' } else { '-' }); - s.push_str(&alloc::format!( - "dx={:+} dy={:+} buttons={}\n", - ev.dx, - ev.dy, - b - )); - } - if s.is_empty() { - s.push_str("mouse: no events\n"); - } - s + let (x, y) = crate::gop::cursor_pos(); + let b = crate::gop::cursor_buttons(); + alloc::format!( + "x={} y={} buttons={}{}{}\n", + x, + y, + if b & 1 != 0 { 'L' } else { '-' }, + if b & 2 != 0 { 'R' } else { '-' }, + if b & 4 != 0 { 'M' } else { '-' } + ) } // --- self-test ------------------------------------------------------------ diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index 0d24d7d..9dedcb8 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -10,11 +10,11 @@ use alloc::vec; use alloc::vec::Vec; use core::fmt; use core::ptr; +use core::sync::atomic::{AtomicU8, AtomicU64, Ordering}; /// Framebuffer state captured before ExitBootServices. pub struct Framebuffer { base: *mut u8, - #[allow(dead_code)] width: usize, height: usize, pitch: usize, // bytes per scan line @@ -119,6 +119,242 @@ pub fn dims() -> (usize, usize) { } } +/// (width, height) of the framebuffer in pixels. +pub fn pixel_dims() -> (usize, usize) { + match fb().as_ref() { + Some(f) => (f.width, f.height), + None => (0, 0), + } +} + +// --------------------------------------------------------------------------- +// Rect primitives + the push path +// --------------------------------------------------------------------------- + +/// Copy a shadow rect to the display and composite the mouse cursor on +/// top. Every display write goes through here (glyph blits, fills, +/// scroll shifts), so the cursor — which lives *only* in this overlay, +/// never in the shadow — stays on top of whatever the content is without +/// any save/restore bookkeeping. The display is never read. +pub(crate) fn push_rect(x: usize, y: usize, w: usize, h: usize) { + 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; + ptr::copy_nonoverlapping(SHADOW.add(off), fb.base.add(off * 4) as *mut u32, w); + } + } + overlay_cursor(x, y, w, h); +} + +/// Fill a rectangle in the shadow and push it (with cursor composited). +pub fn fill_rect(x: usize, y: usize, w: usize, h: usize, color: u32) { + let Some(fb) = fb().as_ref() else { + return; + }; + if x >= fb.width || y >= fb.height { + return; + } + let w = w.min(fb.width - x); + let h = h.min(fb.height - y); + if unsafe { SHADOW.is_null() } { + // Degraded: no backbuffer — write the display directly. + let row_words = fb.pitch / 4; + unsafe { + for dy in 0..h { + let off = (y + dy) * row_words + x; + for dx in 0..w { + ptr::write_volatile(fb.base.add((off + dx) * 4) as *mut u32, color); + } + } + } + return; + } + 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; + } + } + } + push_rect(x, y, w, h); +} + +/// Read one pixel back from the shadow (RAM; cheap). Test/demo use only. +pub fn shadow_pixel(x: usize, y: usize) -> Option { + let fb = fb().as_ref()?; + if x >= fb.width || y >= fb.height || unsafe { SHADOW.is_null() } { + return None; + } + Some(unsafe { *SHADOW.add(y * (fb.pitch / 4) + x) }) +} + +/// Read one pixel back from the display (volatile; uncached on real +/// hardware — test/demo use only, never on a hot path). +pub fn display_pixel(x: usize, y: usize) -> Option { + let fb = fb().as_ref()?; + if x >= fb.width || y >= fb.height { + return None; + } + Some(unsafe { ptr::read_volatile(fb.base.add((y * (fb.pitch / 4) + x) * 4) as *mut u32) }) +} + +// --------------------------------------------------------------------------- +// Mouse cursor — a display overlay, composited by `push_rect`. +// --------------------------------------------------------------------------- + +/// Cursor bitmap: 12×16 classic pointer, 1 = opaque. The hotspot is the +/// top-left corner of the bitmap; the arrow tip sits at (2, 0). Built by +/// a const fn from explicit column lists (pixel column `dx` = bit `11-dx`) +/// so the shape is auditable instead of hand-mangled bit literals. +const CURSOR_W: usize = 12; +const CURSOR_H: usize = 16; + +const fn bmp_row(cols: &[u8]) -> u16 { + let mut v: u16 = 0; + let mut i = 0; + while i < cols.len() { + v |= 1u16 << (11 - cols[i] as u16); + i += 1; + } + v +} + +/// Head: triangle from a 1-px tip at (2,0) widening right to col 11. +/// Tail: 2-px diagonal dropping down-right from the head's base. +const CURSOR_BMP: [u16; CURSOR_H] = [ + bmp_row(&[2]), + bmp_row(&[2, 3]), + bmp_row(&[2, 3, 4]), + bmp_row(&[2, 3, 4, 5]), + bmp_row(&[2, 3, 4, 5, 6]), + bmp_row(&[2, 3, 4, 5, 6, 7]), + bmp_row(&[2, 3, 4, 5, 6, 7, 8]), + bmp_row(&[2, 3, 4, 5, 6, 7, 8, 9]), + bmp_row(&[2, 3, 4, 5, 6, 7, 8, 9, 10]), + bmp_row(&[2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + bmp_row(&[8, 9]), + bmp_row(&[9, 10]), + bmp_row(&[9, 10]), + bmp_row(&[10, 11]), + bmp_row(&[10, 11]), + bmp_row(&[10, 11]), +]; + +/// Packed cursor position: x in the high 32 bits, y in the low 32, so a +/// single relaxed load never sees a torn pair. Written by the cursor +/// task, read by every push (any task). +static CURSOR_POS: AtomicU64 = AtomicU64::new(0); +static CURSOR_BUTTONS: AtomicU8 = AtomicU8::new(0); + +fn pack(x: usize, y: usize) -> u64 { + ((x as u64) << 32) | (y as u64) +} + +/// Current cursor position (top-left of the bitmap). +pub fn cursor_pos() -> (usize, usize) { + let v = CURSOR_POS.load(Ordering::Relaxed); + ((v >> 32) as usize, (v & 0xFFFF_FFFF) as usize) +} + +/// Current button state: bit 0 = left, bit 1 = right, bit 2 = middle. +pub fn cursor_buttons() -> u8 { + CURSOR_BUTTONS.load(Ordering::Relaxed) +} + +pub fn cursor_set_buttons(b: u8) { + CURSOR_BUTTONS.store(b, Ordering::Relaxed); +} + +/// Place the cursor (used at init; centre of the screen). +pub fn cursor_set(x: usize, y: usize) { + CURSOR_POS.store(pack(x, y), Ordering::Relaxed); +} + +/// Move the cursor by a relative delta, clamped to the screen, erasing +/// the old position (a shadow push — the shadow never held the cursor) +/// and drawing the new one. Returns the new position. +pub fn cursor_move(dx: i16, dy: i16) -> (usize, usize) { + let Some(fb) = fb().as_ref() else { + return cursor_pos(); + }; + let (cx, cy) = cursor_pos(); + let max_x = fb.width.saturating_sub(CURSOR_W); + let max_y = fb.height.saturating_sub(CURSOR_H); + let nx = (cx as i64 + dx as i64).clamp(0, max_x as i64) as usize; + let ny = (cy as i64 + dy as i64).clamp(0, max_y as i64) as usize; + if (nx, ny) == (cx, cy) { + return (cx, cy); + } + CURSOR_POS.store(pack(nx, ny), Ordering::Relaxed); + // Restore the old rect (overlay now composites at the new position). + push_rect(cx, cy, CURSOR_W, CURSOR_H); + // Draw at the new position. + push_rect(nx, ny, CURSOR_W, CURSOR_H); + (nx, ny) +} + +/// If the cursor overlaps the given rect, composite it (black outline +/// offset +1,+1, white body on top). Called by `push_rect`. +fn overlay_cursor(x: usize, y: usize, w: usize, h: usize) { + let (cx, cy) = cursor_pos(); + if x >= cx + CURSOR_W || y >= cy + CURSOR_H { + return; + } + if cx >= x + w || cy >= y + h { + return; + } + draw_bmp(cx + 1, cy + 1, rgb(0, 0, 0)); + draw_bmp(cx, cy, rgb(255, 255, 255)); +} + +/// Re-composite the cursor where it is (after a scroll shift, which +/// bypasses `push_rect`, and once at init so it is visible over idle +/// cells the diff never re-blits). +pub(crate) fn cursor_redraw() { + let (cx, cy) = cursor_pos(); + draw_bmp(cx + 1, cy + 1, rgb(0, 0, 0)); + draw_bmp(cx, cy, rgb(255, 255, 255)); +} + +/// Write the cursor bitmap at (x, y) in one colour — display only, the +/// shadow is never touched (that is what makes the overlay race-free). +fn draw_bmp(x: usize, y: usize, color: u32) { + let Some(fb) = fb().as_ref() else { + return; + }; + let row_words = fb.pitch / 4; + unsafe { + for dy in 0..CURSOR_H { + let yy = y + dy; + if yy >= fb.height { + break; + } + let row = CURSOR_BMP[dy]; + for dx in 0..CURSOR_W { + let xx = x + dx; + if xx >= fb.width { + break; + } + if row & (1u16 << (11 - dx)) != 0 { + let off = yy * row_words + xx; + ptr::write_volatile(fb.base.add(off * 4) as *mut u32, color); + } + } + } + } +} + /// Pack an (r, g, b) colour for the framebuffer's pixel format. pub fn rgb(r: u8, g: u8, b: u8) -> u32 { match fb().as_ref() { @@ -191,6 +427,9 @@ impl Framebuffer { // coalesce well on WC framebuffers — far fewer bus // transactions than per-pixel stores. ptr::copy_nonoverlapping(SHADOW, self.base as *mut u32, words); + // The scroll copied the shadow (which never contains the + // cursor); re-composite it on top of the pushed frame. + crate::gop::cursor_redraw(); } } } @@ -363,20 +602,35 @@ pub(crate) fn blit_glyph(col: usize, row: usize, ch: u16, fg: (u8, u8, u8), bg: let x = col * 8; let y = row * 16; + if unsafe { SHADOW.is_null() } { + // Degraded: no backbuffer — write the display directly, no + // cursor overlay. + for cy in 0..16 { + let row_bits = data[cy]; + for cx in 0..8 { + let offset = (y + cy) * fb.pitch + (x + cx) * fb.bytes_per_pixel; + let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg }; + unsafe { + ptr::write_volatile(fb.base.add(offset) as *mut u32, px); + } + } + } + return; + } + + // Normal path: rasterise into the RAM shadow, then one push + // copies the cell to the display and composites the cursor. for cy in 0..16 { let row_bits = data[cy]; for cx in 0..8 { let offset = (y + cy) * fb.pitch + (x + cx) * fb.bytes_per_pixel; + let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg }; unsafe { - let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg }; - // Display (volatile; the fb is MMIO-ish) + RAM shadow. - ptr::write_volatile(fb.base.add(offset) as *mut u32, px); - if !SHADOW.is_null() { - *SHADOW.add(offset / 4) = px; - } + *SHADOW.add(offset / 4) = px; } } } + push_rect(x, y, 8, 16); } } diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs index 08ac6c2..f4645a5 100644 --- a/rsnix/src/lib.rs +++ b/rsnix/src/lib.rs @@ -3,6 +3,7 @@ extern crate alloc; pub mod ahci; +pub mod cursor; pub mod fat; pub mod font; pub mod fs; -- cgit v1.2.3