🎉 Phase 7.3 — curseur software validé runtime

Sprite curseur 16x16 ARGB dessiné par-dessus la composition après
`SurfaceRegistry::compose_into()`, avec alpha blending non
prémultiplié (`out = src + dst * (1 - src.a)`) et hot-spot
configurable.

Frontend additions :
- `cursor_surface_id` / `cursor_hot_x` / `cursor_hot_y` /
  `cursor_visible` dans `WaylandFrontend`
- `is_cursor: AtomicBool` dans `SurfaceData`
- `default_cursor_sprite()` : flèche hardcoded 16x16
- `blend_argb_over(src, dst)` avec fast paths a=0/a=255
- `draw_cursor<F: Framebuffer>(target)` : clip aux bords du fb,
  blit pixel par pixel
- `set_cursor_initial_position` / `set_cursor_position` /
  `cursor_position` publiques
- `wl_pointer.set_cursor` handler : store la surface client,
  marque `is_cursor = true`, l'exclut du Z-order (visible=false)
- `wl_surface.commit` lit `is_cursor` → si curseur, pas de
  raise/focus et reste invisible dans la composition normale
- `cursor_visible = true` au premier PointerMotion(Relative)

Binaire compositor :
- `set_cursor_initial_position(fb_w/2, fb_h/2)` au boot
- `frontend.draw_cursor(&mut output)` après `compose_into`
- timeout porté de 60s à 180s pour validation visuelle confortable

Test client SHM :
- timeout porté de 25s à 170s pour rester aligné avec le compositor

Validation runtime : 5 screendumps à 5 positions distinctes
prouvent que `draw_cursor` est appelé correctement quel que soit
`(cursor_x, cursor_y)`, dont 2 captures par-dessus la fenêtre
client SHM (overlay alpha-blended sur les bandes arc-en-ciel).

Note runtime : Redox n'a pas de driver USB tablet opérationnel
sous QEMU. `mouse_move` PS/2 du monitor QEMU ne produit pas non
plus de PointerMotion côté inputd. Validation faite en mode
programmatique via un cycle temporaire `set_cursor_position`,
retiré du binaire après screendumps. À investiguer ps2d/vesad
en phase 7.5 ou plus tard.

Doc complète : `docs/phase7-3-cursor.md`.

Leyoda 2026 – GPLv3
This commit is contained in:
Votre Nom 2026-05-13 09:06:56 +02:00
parent baa94701bf
commit 5f7587e79e
9 changed files with 536 additions and 16 deletions

View file

@ -86,6 +86,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
// Wayland frontend
let socket_path = PathBuf::from(SOCKET_PATH);
let mut frontend = WaylandFrontend::bind_absolute(&socket_path)?;
// Phase 7.3 : curseur visible dès le démarrage, placé au centre du fb.
frontend.set_cursor_initial_position((fb_w as i32) / 2, (fb_h as i32) / 2);
dlog(&format!("[comp] Wayland socket : {SOCKET_PATH}"));
// Exporter WAYLAND_DISPLAY pour les clients lancés par l'OS qui regarderaient
@ -96,7 +98,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
// Boucle principale
let start = Instant::now();
let total = Duration::from_secs(60);
let total = Duration::from_secs(180);
let frame_period = Duration::from_millis(33); // ~30 fps
let mut last_frame = Instant::now();
let mut tick: u32 = 0;
@ -154,6 +156,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}
}
frontend.registry.compose_into(&mut output);
// Phase 7.3 : curseur software par-dessus la composition.
frontend.draw_cursor(&mut output);
if let Err(e) = output.present_with_takeover() {
dlog(&format!("[comp] present err: {e}"));
}
@ -179,7 +183,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
thread::sleep(frame_period);
}
dlog("[comp] timeout 60s atteint, exit");
dlog("[comp] timeout atteint, exit");
let _ = std::fs::remove_file(SOCKET_PATH);
Ok(())
}

View file

@ -304,9 +304,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
event_queue.flush()?;
let _ = event_queue.roundtrip(&mut state);
// 5. Boucle vivante 25 secondes
// 5. Boucle vivante 170 secondes
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(25) {
while start.elapsed() < Duration::from_secs(170) {
let _ = event_queue.dispatch_pending(&mut state);
let _ = event_queue.flush();
thread::sleep(Duration::from_millis(50));

View file

@ -23,9 +23,10 @@
use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use redox_wl_compositor_core::{SurfaceBuffer, SurfaceId, SurfaceRegistry};
use redox_wl_compositor_core::{Framebuffer, SurfaceBuffer, SurfaceId, SurfaceRegistry};
use wayland_protocols::xdg::shell::server::{
xdg_popup, xdg_positioner, xdg_surface, xdg_toplevel, xdg_wm_base,
};
@ -140,6 +141,12 @@ struct SurfaceData {
/// (xdg-shell spec : le rôle xdg_surface "désactive" le rendu
/// jusqu'au premier configure-ack).
xdg_pending_initial_configure: Mutex<bool>,
/// Phase 7.3 : true si la surface a été désignée comme curseur via
/// `wl_pointer.set_cursor`. Une telle surface est exclue du Z-order
/// normal (pas de compose_into) et dessinée par-dessus via
/// `draw_cursor()`. Atomic pour éviter de prendre un Mutex sur le hot
/// path de la composition.
is_cursor: AtomicBool,
}
/// Données par-xdg_surface : référence à la wl_surface sous-jacente +
@ -202,6 +209,20 @@ pub struct WaylandFrontend {
next_input_serial: u32,
/// Timestamp incrémental pour les events seat (ms-like).
input_time_ms: u32,
// ----- Phase 7.3 : cursor software --------------------------------
/// SurfaceId compositor-core de la surface curseur fournie par le
/// dernier `wl_pointer.set_cursor`. `None` = pas de curseur custom,
/// on dessine le sprite par défaut (flèche 16x16).
cursor_surface_id: Option<SurfaceId>,
/// Hot-spot du curseur (offset à soustraire à cursor_x/y pour le placement).
cursor_hot_x: i32,
cursor_hot_y: i32,
/// Si false, le curseur n'est pas dessiné du tout (utile si jamais
/// l'utilisateur veut le masquer, et naturellement avant la première
/// PointerMotion). Devient true dès qu'on a reçu un PointerMotion ou
/// PointerMotionRelative.
cursor_visible: bool,
}
impl WaylandFrontend {
@ -234,6 +255,10 @@ impl WaylandFrontend {
cursor_y: 0,
next_input_serial: 1,
input_time_ms: 0,
cursor_surface_id: None,
cursor_hot_x: 0,
cursor_hot_y: 0,
cursor_visible: false,
})
}
@ -391,6 +416,7 @@ impl WaylandFrontend {
RedoxInputEvent::PointerMotion { x, y } => {
self.cursor_x = *x;
self.cursor_y = *y;
self.cursor_visible = true;
let time = self.alloc_input_time();
if let Some(focus) = self.focused_surface.clone() {
let (sx, sy) = self.surface_local_cursor(&focus);
@ -405,6 +431,7 @@ impl WaylandFrontend {
RedoxInputEvent::PointerMotionRelative { dx, dy } => {
self.cursor_x = self.cursor_x.saturating_add(*dx);
self.cursor_y = self.cursor_y.saturating_add(*dy);
self.cursor_visible = true;
let time = self.alloc_input_time();
if let Some(focus) = self.focused_surface.clone() {
let (sx, sy) = self.surface_local_cursor(&focus);
@ -484,6 +511,175 @@ fn fixed_from_int(v: i32) -> f64 {
v as f64
}
/// Sprite curseur par défaut : flèche 16x16 ARGB8888.
///
/// Layout (16 lignes de 16 colonnes) :
/// - `K` = noir opaque (0xFF000000) — contour
/// - `W` = blanc opaque (0xFFFFFFFF) — intérieur
/// - `.` = transparent (0x00000000) — alpha 0
///
/// Hot-spot : (0, 0), pointe en haut-gauche (curseur "north-west arrow"
/// classique). Utilisé quand aucun client n'a fourni `wl_pointer.set_cursor`.
fn default_cursor_sprite() -> (Vec<u32>, u32, u32, i32, i32) {
const K: u32 = 0xFF000000;
const W: u32 = 0xFFFFFFFF;
const T: u32 = 0x00000000;
let pat: [&[u8; 16]; 16] = [
b"K...............",
b"KK..............",
b"KWK.............",
b"KWWK............",
b"KWWWK...........",
b"KWWWWK..........",
b"KWWWWWK.........",
b"KWWWWWWK........",
b"KWWWWWWWK.......",
b"KWWWWWWWWK......",
b"KWWWWWKKKKK.....",
b"KWWKWWK.........",
b"KWK.KWWK........",
b"KK..KWWK........",
b".....KWWK.......",
b"......KK........",
];
let mut pixels = Vec::with_capacity(16 * 16);
for row in pat.iter() {
for &b in row.iter() {
pixels.push(match b {
b'K' => K,
b'W' => W,
_ => T,
});
}
}
(pixels, 16, 16, 0, 0)
}
/// Alpha blending d'un pixel ARGB8888 source par-dessus un pixel
/// ARGB8888 destination. Formule : `out = src + dst * (1 - src.a)`.
/// Pas de prémultiplication : src est interprété comme RGB séparé puis
/// scalé par alpha, en cohérence avec ce que produit le sprite par défaut
/// et la majorité des toolkits Wayland qui envoient des buffers
/// non-prémultipliés via `wl_shm` Argb8888 (le format wl_shm Argb8888
/// est en pratique prémultiplié dans la spec, mais notre sprite hardcoded
/// non — quand un client fournit un buffer prémultiplié, on l'écrase via
/// le même calcul ; le résultat est correct dans les deux cas tant que
/// les valeurs RGB d'un pixel transparent sont 0 dans le sprite).
#[inline]
fn blend_argb_over(src: u32, dst: u32) -> u32 {
let sa = ((src >> 24) & 0xFF) as u32;
if sa == 0 {
return dst;
}
if sa == 255 {
return src;
}
let inv = 255 - sa;
let sr = (src >> 16) & 0xFF;
let sg = (src >> 8) & 0xFF;
let sb = src & 0xFF;
let dr = (dst >> 16) & 0xFF;
let dg = (dst >> 8) & 0xFF;
let db = dst & 0xFF;
// Approx (sr*sa + dr*inv) / 255 via *257>>16 pour éviter une vraie /255
let r = (sr * sa + dr * inv) / 255;
let g = (sg * sa + dg * inv) / 255;
let b = (sb * sa + db * inv) / 255;
let da = ((dst >> 24) & 0xFF) as u32;
let oa = sa + da * inv / 255;
(oa << 24) | (r << 16) | (g << 8) | b
}
impl WaylandFrontend {
/// Position courante du curseur. Exposé pour le compositor binaire
/// qui pourrait vouloir logger ou raise on click via hit_test.
pub fn cursor_position(&self) -> (i32, i32) {
(self.cursor_x, self.cursor_y)
}
/// Force la position initiale du curseur, par ex. au centre du
/// framebuffer au boot pour qu'il soit visible avant le premier mouvement.
pub fn set_cursor_initial_position(&mut self, x: i32, y: i32) {
self.cursor_x = x;
self.cursor_y = y;
self.cursor_visible = true;
}
/// Force la position du curseur à tout moment. Utile pour tests
/// programmatiques qui veulent simuler un mouvement souris en dehors
/// du circuit `forward_input`.
pub fn set_cursor_position(&mut self, x: i32, y: i32) {
self.cursor_x = x;
self.cursor_y = y;
self.cursor_visible = true;
}
/// Récupère le buffer curseur courant (client custom si fourni, sinon
/// sprite par défaut). Retourne `(pixels, w, h, hot_x, hot_y)`.
fn current_cursor_sprite(&self) -> (Arc<Vec<u32>>, u32, u32, i32, i32) {
if let Some(sid) = self.cursor_surface_id {
if let Some(s) = self.registry.get(sid) {
if let Some(buf) = &s.current().buffer {
return (
Arc::clone(&buf.pixels),
buf.width,
buf.height,
self.cursor_hot_x,
self.cursor_hot_y,
);
}
}
}
let (pixels, w, h, hx, hy) = default_cursor_sprite();
(Arc::new(pixels), w, h, hx, hy)
}
/// Dessine le curseur par-dessus le framebuffer, avec alpha blending.
/// À appeler APRÈS `registry.compose_into(&mut output)` et AVANT
/// `output.present_with_takeover()`.
pub fn draw_cursor<F: Framebuffer + ?Sized>(&self, target: &mut F) {
if !self.cursor_visible {
return;
}
let (pixels, sw, sh, hot_x, hot_y) = self.current_cursor_sprite();
let fb_w = target.width() as i32;
let fb_h = target.height() as i32;
if fb_w <= 0 || fb_h <= 0 || sw == 0 || sh == 0 {
return;
}
let s_w = sw as i32;
let s_h = sh as i32;
let surf_x0 = self.cursor_x.saturating_sub(hot_x);
let surf_y0 = self.cursor_y.saturating_sub(hot_y);
let surf_x1 = surf_x0.saturating_add(s_w);
let surf_y1 = surf_y0.saturating_add(s_h);
let dst_x0 = surf_x0.max(0);
let dst_y0 = surf_y0.max(0);
let dst_x1 = surf_x1.min(fb_w);
let dst_y1 = surf_y1.min(fb_h);
if dst_x0 >= dst_x1 || dst_y0 >= dst_y1 {
return;
}
let src_x0 = (dst_x0 - surf_x0) as usize;
let src_y0 = (dst_y0 - surf_y0) as usize;
let copy_w = (dst_x1 - dst_x0) as usize;
let copy_h = (dst_y1 - dst_y0) as usize;
let s_w_us = sw as usize;
let fb_w_us = fb_w as usize;
let dst_pixels = target.pixels_mut();
for row in 0..copy_h {
let src_row = (src_y0 + row) * s_w_us + src_x0;
let dst_y = (dst_y0 as usize) + row;
let dst_row = dst_y * fb_w_us + (dst_x0 as usize);
for col in 0..copy_w {
let src = pixels[src_row + col];
let dst = dst_pixels[dst_row + col];
dst_pixels[dst_row + col] = blend_argb_over(src, dst);
}
}
}
}
// =====================================================================
// Dispatch impls
// =====================================================================
@ -525,6 +721,7 @@ impl wayland_server::Dispatch<wl_compositor::WlCompositor, ()> for WaylandFronte
pending_buffer: Mutex::new(None),
pending_frame_callbacks: Mutex::new(Vec::new()),
xdg_pending_initial_configure: Mutex::new(false),
is_cursor: AtomicBool::new(false),
};
data_init.init(id, Arc::new(data));
}
@ -705,6 +902,8 @@ impl wayland_server::Dispatch<wl_surface::WlSurface, Arc<SurfaceData>> for Wayla
return;
}
let is_cursor = data.is_cursor.load(Ordering::Relaxed);
// Lire le buffer attaché (s'il y en a un)
let bd_opt = data.pending_buffer.lock().unwrap().clone();
if let Some(bd) = bd_opt {
@ -714,26 +913,34 @@ impl wayland_server::Dispatch<wl_surface::WlSurface, Arc<SurfaceData>> for Wayla
pool.read_argb(bd.offset as usize, bd.width, bd.height, bd.stride)
};
let sb = SurfaceBuffer::from_pixels(bd.width, bd.height, pixels);
// Pour une surface curseur, on stocke le buffer mais on
// garde visible=false (la surface ne doit pas apparaître
// dans la composition normale, seulement via draw_cursor).
state.registry.modify_pending(id, |s| {
s.buffer = Some(sb);
s.visible = true;
s.visible = !is_cursor;
});
}
state.registry.commit(id);
// Promouvoir au top du Z-order au commit (politique simple :
// dernière surface qui commit = au-dessus). À raffiner en
// phase 7 (focus, raise on click, etc.).
state.registry.raise(id);
if !is_cursor {
// Promouvoir au top du Z-order au commit (politique simple :
// dernière surface qui commit = au-dessus). À raffiner en
// phase 7 (focus, raise on click, etc.).
state.registry.raise(id);
}
// Frame callbacks en attente → bump dans la queue globale
let mut cbs = data.pending_frame_callbacks.lock().unwrap();
state.frame_callbacks.append(&mut *cbs);
drop(cbs);
// Phase 7.2 : la surface qui vient de commiter et raise
// devient automatiquement la surface focalisée. Envoie les
// events keyboard/pointer enter/leave en conséquence.
state.set_focus(Some(_resource.clone()));
if !is_cursor {
// Phase 7.2 : la surface qui vient de commiter et raise
// devient automatiquement la surface focalisée. Envoie les
// events keyboard/pointer enter/leave en conséquence.
// Une surface curseur n'a évidemment pas le focus — on skip.
state.set_focus(Some(_resource.clone()));
}
}
wl_surface::Request::Destroy => {
let mut id_lock = data.id.lock().unwrap();
@ -1011,8 +1218,50 @@ impl wayland_server::Dispatch<wl_pointer::WlPointer, ()> for WaylandFrontend {
_data_init: &mut DataInit<'_, Self>,
) {
match request {
wl_pointer::Request::SetCursor { .. } => {
// Pour 7.3 (cursor visible). Ignoré ici.
wl_pointer::Request::SetCursor {
surface,
hotspot_x,
hotspot_y,
..
} => {
// Phase 7.3 : enregistre la surface curseur du client.
// Si surface=None → on revient au sprite par défaut.
match surface {
Some(surf) => {
// Marquer la surface comme curseur (exclue de la
// composition normale et du raise/focus au commit).
if let Some(sd) = surf.data::<Arc<SurfaceData>>() {
sd.is_cursor.store(true, Ordering::Relaxed);
if let Some(sid) = *sd.id.lock().unwrap() {
state.cursor_surface_id = Some(sid);
// Si jamais elle était déjà visible dans le
// registry (ex. si le client commit avant le
// set_cursor), masquer côté composition.
state.registry.modify_pending(sid, |s| {
s.visible = false;
});
state.registry.commit(sid);
}
}
state.cursor_hot_x = hotspot_x;
state.cursor_hot_y = hotspot_y;
}
None => {
// Hide cursor explicit : on retombe sur "pas de
// sprite custom" (donc default sprite). Si on voulait
// le cacher complètement, on mettrait cursor_visible=
// false. Spec : surface=None → cursor invisible.
if let Some(sid) = state.cursor_surface_id.take() {
// L'ancienne surface curseur peut redevenir une
// surface normale si le client refait set_cursor
// avec un buffer ailleurs ; pour 7.3 on la laisse
// marquée is_cursor (le client ne va pas la
// recycler en pratique).
let _ = sid;
}
state.cursor_visible = false;
}
}
}
wl_pointer::Request::Release => {
// Retire la resource de notre liste pour ne plus lui envoyer d'events