diff --git a/crates/pf-inject/src/inject/linux/libei.rs b/crates/pf-inject/src/inject/linux/libei.rs index 0775660f..3ba6ba68 100644 --- a/crates/pf-inject/src/inject/linux/libei.rs +++ b/crates/pf-inject/src/inject/linux/libei.rs @@ -422,6 +422,30 @@ fn region_for_mode<'a>( .or_else(|| regions.first()) } +/// Report which region absolute coordinates actually landed in, once per distinct answer. +/// +/// The ladder above is only *observable* through where the pointer ends up, which on a two-head box +/// is precisely the thing that is hard to see and easy to get wrong — the anchor exists because it +/// already resolved wrong on-glass once, silently. `warn_anchor_miss` covers the anchor naming +/// nothing; this covers the other half, an anchor that matched *something*, by saying which. Once +/// per distinct region so a live session logs one line, not one per motion event. +fn note_abs_region(region: &reis::event::Region, anchor: Option<&AbsoluteAnchor>) { + static LAST: std::sync::Mutex> = std::sync::Mutex::new(None); + let key = (region.x, region.y, region.width, region.height); + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + if *last == Some(key) { + return; + } + *last = Some(key); + tracing::info!( + region = %format!("{}x{}+{}+{}", region.width, region.height, region.x, region.y), + mapping_id = ?region.mapping_id, + anchor_origin = ?anchor.and_then(|a| a.origin), + anchor_mapping_id = ?anchor.and_then(|a| a.mapping_id.clone()), + "libei: absolute input maps into this output" + ); +} + /// Did an anchor name an output this region set doesn't have? Drives the one-shot warning — a /// silently mis-mapped pointer is the failure this whole ladder exists to prevent, so when the /// anchor can't be honored that has to be visible in the log rather than inferred from "clicks land @@ -847,10 +871,13 @@ impl EiState { let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref()) .filter(|r| sane_region(r)) { - Some(region) => ( - region.x as f32 + nx * region.width as f32, - region.y as f32 + ny * region.height as f32, - ), + Some(region) => { + note_abs_region(region, anchor.as_ref()); + ( + region.x as f32 + nx * region.width as f32, + region.y as f32 + ny * region.height as f32, + ) + } // Degenerate/absent region: scale into the relay-file output hint // (correct even when the client streams at a different resolution // than the session runs); raw client pixels as the last resort. @@ -935,10 +962,13 @@ impl EiState { let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref()) .filter(|r| sane_region(r)) { - Some(region) => ( - region.x as f32 + nx * region.width as f32, - region.y as f32 + ny * region.height as f32, - ), + Some(region) => { + note_abs_region(region, anchor.as_ref()); + ( + region.x as f32 + nx * region.width as f32, + region.y as f32 + ny * region.height as f32, + ) + } None => match self.output_hint { Some((ow, oh)) => (nx * ow as f32, ny * oh as f32), None => (ev.x as f32, ev.y as f32), diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 16cf3aa9..d1bdf8f4 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -597,3 +597,133 @@ pub fn mirror_test(args: &[String]) -> Result<()> { } Ok(()) } + +/// Aim absolute input at a named monitor and prove where it landed — the on-glass gate for the +/// input-region ladder (`design/per-monitor-portal-capture.md` §7.2), without needing a client. +/// +/// The ladder exists for one case a unit test can only simulate: **two heads of the same size**, +/// where matching a libei region by the streamed mode is a coin flip and the pointer silently ends +/// up on the wrong screen. This drives the real thing — the compositor's own EIS regions, the real +/// anchor, the real resolver — and prints the region absolute coordinates actually mapped into, so +/// "it went to the right monitor" is something you read rather than infer. +/// +/// `--monitor ` anchors at that head's origin (default: the `PUNKTFUNK_CAPTURE_MONITOR` / +/// policy pin); `--none` deliberately runs UNANCHORED, which is the A/B that makes the anchored run +/// mean something on a same-size pair. It then walks the corners and centre of a `--width`×`--height` +/// client surface so an observer can watch the pointer. +/// +/// Read the answer from the log line `libei: absolute input maps into this output`. +#[cfg(target_os = "linux")] +pub fn anchor_test(args: &[String]) -> Result<()> { + use punktfunk_core::input::{InputEvent, InputKind}; + use std::time::Duration; + let arg = |name: &str| { + args.iter() + .skip_while(|a| a.as_str() != name) + .nth(1) + .cloned() + }; + let unanchored = args.iter().any(|a| a == "--none"); + let w: u32 = arg("--width").and_then(|s| s.parse().ok()).unwrap_or(1920); + let h: u32 = arg("--height").and_then(|s| s.parse().ok()).unwrap_or(1080); + + let compositor = crate::vdisplay::detect()?; + let monitors = crate::vdisplay::monitors::list(compositor)?; + println!( + "anchor-test: {compositor:?} has {} monitor(s):", + monitors.len() + ); + for m in &monitors { + println!( + " {:<12} {:>13} at +{},+{}", + m.connector, + m.mode_label(), + m.x, + m.y + ); + } + // Two heads at the same size is the case the ladder exists for; say so when the rig is right, + // and say so when it is NOT — a green run on a single-head box proves nothing about it. + let same_size = monitors.iter().enumerate().any(|(i, a)| { + monitors + .iter() + .skip(i + 1) + .any(|b| a.width == b.width && a.height == b.height) + }); + println!( + "anchor-test: two same-size heads present: {} {}", + same_size, + if same_size { + "— this run exercises the case the ladder exists for" + } else { + "— WEAK RIG: size matching would have picked correctly anyway" + } + ); + + if unanchored { + crate::inject::set_absolute_anchor(None); + println!("anchor-test: UNANCHORED (--none) — the size/first rungs decide"); + } else { + let want = arg("--monitor") + .or_else(crate::vdisplay::capture_monitor) + .context("no monitor named — pass --monitor , or --none for the A/B")?; + let m = crate::vdisplay::monitors::resolve(&monitors, &want)?; + crate::inject::set_absolute_anchor(Some(crate::inject::AbsoluteAnchor { + origin: Some((m.x, m.y)), + mapping_id: None, + })); + println!( + "anchor-test: anchored at {} +{},+{} ({})", + m.connector, + m.x, + m.y, + m.mode_label() + ); + } + + let backend = crate::inject::default_backend(); + if backend != crate::inject::Backend::Libei { + // The ladder is libei's; on any other backend this command would report nothing about it. + // Say so rather than emitting a green run that means nothing (sway injects via WlrVirtual, + // which is why the sway box cannot serve as this rig — set PUNKTFUNK_INPUT_BACKEND=libei on + // a compositor that speaks EI). + anyhow::bail!( + "input backend is {backend:?}, not libei — the absolute-region ladder only exists on \ + the libei backend; set PUNKTFUNK_INPUT_BACKEND=libei" + ); + } + let mut inj = crate::inject::open(backend)?; + // libei establishes its portal/EIS session + device resume asynchronously; events before then + // are dropped (and it is the resume that publishes the regions we are testing). + std::thread::sleep(Duration::from_secs(4)); + + let flags = (w << 16) | (h & 0xffff); + let pts = [ + (w as i32 / 2, h as i32 / 2), + (60, 60), + (w as i32 - 60, 60), + (w as i32 - 60, h as i32 - 60), + (60, h as i32 - 60), + (w as i32 / 2, h as i32 / 2), + ]; + println!("anchor-test: walking {w}x{h} — centre, four corners, centre (1s apart)"); + for (x, y) in pts { + let e = InputEvent { + kind: InputKind::MouseMoveAbs, + _pad: [0; 3], + code: 0, + x, + y, + flags, + }; + if let Err(err) = inj.inject(&e) { + tracing::warn!(error = %format!("{err:#}"), "anchor-test: inject failed"); + } + std::thread::sleep(Duration::from_secs(1)); + } + println!( + "anchor-test: done — read the `libei: absolute input maps into this output` line above \ + for the region that was chosen" + ); + Ok(()) +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index be90c93b..fd0ad058 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -205,6 +205,16 @@ pub(crate) fn refresh_capture_monitor_anchor(context: &str) { let Some(want) = pf_vdisplay::capture_monitor() else { // No pin (or the console just cleared one): stop aiming input at a monitor we are no // longer mirroring, or a later virtual-display session inherits a stale anchor. + // Setting a pin says so in the log; clearing one is the same size of change to what the + // host streams, so say that too — but only when there WAS one, or every unpinned host + // logs this line at startup for no reason. + if pf_inject::absolute_anchor().is_some() { + tracing::info!( + context, + "capture monitor: cleared — sessions create a virtual display again and absolute \ + input is no longer anchored" + ); + } pf_inject::set_absolute_anchor(None); return; }; @@ -532,6 +542,10 @@ fn real_main() -> Result<()> { // on-glass gate, with no client involved. #[cfg(target_os = "linux")] Some("mirror-test") => devtest::mirror_test(&args), + // Aim absolute input at a named monitor and report which libei region it landed in — the + // on-glass gate for the input-region ladder, with no client involved. + #[cfg(target_os = "linux")] + Some("anchor-test") => devtest::anchor_test(&args), // Create a virtual DualSense via UHID and exercise it (validation, no streaming session). #[cfg(target_os = "linux")] Some("dualsense-test") => devtest::dualsense_test(&args),