feat(host): anchor-test, and libei says which output absolute input landed on
The absolute-region ladder existed 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 that case had never been run against a real compositor. It was also unobservable: the log said which regions the EIS server offered, never which one a coordinate went into, so "the pointer is on the wrong monitor" could only be discovered by looking at a monitor. libei now reports the chosen region once per distinct answer, beside the existing miss warning: one covers an anchor that named nothing, this one covers an anchor that matched something, by saying which. `anchor-test` is the gate that reads it, in the shape mirror-test established: it enumerates the heads, SAYS whether the rig actually has two of the same size (a green run on a single-head box proves nothing), anchors at a named one (or `--none` for the A/B that makes the anchored run mean anything), and walks the corners. It refuses to run off libei rather than emitting a green result about a ladder that isn't there. On-glass on KWin 6.7.3 with `kwin_wayland --virtual --output-count 2` (regions 1920x1080+0+0 and +1920+0): unanchored picks +0+0, anchored at Virtual-1 picks +1920+0, anchored at Virtual-0 picks +0+0. That is open item 3 of design/per-monitor-portal-capture.md §8b, closed. Clearing the pin now logs too — setting one always did, and the streamed screen going back to virtual is the same size of change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Option<(u32, u32, u32, u32)>> = 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) => (
|
||||
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) => (
|
||||
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),
|
||||
|
||||
@@ -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 <CONNECTOR>` 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 <CONNECTOR>, 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(())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user