Compare commits

...
Author SHA1 Message Date
enricobuehler 46eb1083ca fix(capture/windows): the masked-color belt failed loud, and two tests outlived their helper
Review follow-ups to #270, on top of it.

`apply_and_mask_alpha` survived as `#[cfg(test)]` with no production caller, and its
two tests came along. `a_short_mask_does_not_panic` even documented "the caller has
already required `mask.h >= color.h`" — there is no caller. Two of the nine tests read
as coverage of the shipping conversion and covered nothing, so both go with the helper.
The short-mask belt they were guarding is re-asserted against `masked_color_to_rgba`,
which is where it actually lives now.

That belt also pointed the wrong way. `mask_bgra.get(..).is_some_and(..)` reads a
missing mask byte as AND=0, which lands in the opaque arms and paints a black box over
the pointer — the loudest possible way to be wrong, in the one helper whose whole bug
history is about pixels going where they should not. Either source running short now
leaves the pixel transparent. `convert` still rejects `mask.h < color.h`, so this stays
unreachable; a belt should fail quiet. The new test fails against the old fallback
(the tail came back opaque white) and passes against this one.

Also runs rustfmt: #270 left two `assert_eq!` calls unwrapped, and `ci / rust` checks
`cargo fmt --all --check` on every PR with no path filter. rustfmt reads source text,
not compiled targets, so it is the one CI leg that sees this Windows-only file — the
PR was going to go red on it once the fleet stopped cancelling its checks at 0s.

Gated on the windows-amd64 runner (.133), both CI lines verbatim against a fresh
target dir: `cargo clippy --release -p pf-capture --all-targets -- -D warnings` rc=0,
`cargo test --release -p pf-capture` rc=0 (33 passed, 1 ignored — the pre-existing
live-GPU `hdr_p010_selftest_intel_1080_live`, headless VM). All eight cursor_poll
tests ran. `cargo fmt --all --check` clean on the workspace.
2026-08-16 21:23:29 +02:00
enricobuehlerandCursor 639886357d fix(capture/windows): a masked-color I-beam was forwarded as a fully transparent pointer
ci / web (pull_request) Successful in 1m1s
ci / rust-arm64 (pull_request) Failing after 3m58s
ci / bun-nix (pull_request) Successful in 7m24s
ci / docs-site (pull_request) Successful in 8m35s
ci / rust (pull_request) Failing after 9m18s
android / android (pull_request) Failing after 55s
AND=1 plus a non-zero colour pixel is invert, not transparent. Treating it as a simple alpha mask dropped the text cursor, so the client installed nothing over every text field.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 22:16:05 +05:30
enricobuehler d92266a402 Merge pull request 'The console UI overhaul: motion, look, and a library you can actually navigate' (#269) from worktree-console-ui-overhaul into main
ci / web (push) Successful in 1m12s
ci / rust-arm64 (push) Successful in 1m27s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m13s
ci / bun-nix (push) Successful in 4m31s
deb / build-publish (push) Successful in 3m53s
ci / docs-site (push) Successful in 5m56s
deb / build-publish-host (push) Successful in 4m57s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
android / android (push) Successful in 7m30s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 2m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
deb / build-publish-gamescope (push) Successful in 3m46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m52s
docker / deploy-docs (push) Successful in 2m9s
arch / build-publish (push) Successful in 12m1s
docker / builders-arm64cross (push) Successful in 1m24s
deb / smoke-install (push) Successful in 3m32s
ci / rust (push) Failing after 13m34s
flatpak / build-publish (push) Successful in 9m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m53s
Reviewed-on: #269
2026-08-16 15:06:19 +00:00
@@ -481,7 +481,11 @@ fn bitmap_extent(hbm: HBITMAP) -> Option<(u32, u32)> {
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
/// masked-color cursors, including Windows 11's coloured I-beam) the AND mask is NOT just
/// transparency — it is the same four-state table as monochrome, with the colour bitmap
/// standing in for the XOR plane. Treating AND=1 as "always transparent" drops invert
/// pixels, and the text I-beam is almost entirely invert, so the client would install a
/// fully-transparent pointer over every text field.
/// - monochrome (`hbmColor` null): `hbmMask` is DOUBLE height — AND plane over XOR plane, the
/// WebRTC truth table: (0,0) black, (0,1) white, (1,0) transparent, (1,1) invert. Invert
/// pixels — unrepresentable in straight alpha — become opaque black with a white outline
@@ -497,12 +501,12 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
let (w, h) = (color.w as u32, color.h as u32);
let mut rgba = bgra_to_rgba(&color.bgra);
if alpha_is_empty(&rgba) {
// Alpha-less color cursor: transparency lives in the AND mask.
// Alpha-less color cursor: AND + colour-as-XOR, including invert.
let mask = read_bitmap_32(dc, ii.hbmMask)?;
if mask.w != color.w || mask.h < color.h {
return None;
}
apply_and_mask_alpha(&mut rgba, &mask.bgra);
rgba = masked_color_to_rgba(&rgba, &mask.bgra, w as usize, h as usize);
}
Some((rgba, w, h))
} else {
@@ -596,18 +600,44 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
}
/// Whether a 32bpp RGBA buffer's alpha channel is entirely zero — the "old-style cursor with no
/// alpha" test, whose transparency lives in the AND mask instead ([`apply_and_mask_alpha`]).
/// alpha" test, whose transparency (and invert) live in the AND mask instead
/// ([`masked_color_to_rgba`]).
fn alpha_is_empty(rgba: &[u8]) -> bool {
rgba.chunks_exact(4).all(|p| p[3] == 0)
}
/// Take alpha from an expanded AND mask: mask WHITE (AND bit 1) means transparent, black opaque.
/// `mask_bgra` is the 32bpp expansion `GetDIBits` produces from the 1bpp mask, so any non-zero
/// channel byte is "set".
fn apply_and_mask_alpha(rgba: &mut [u8], mask_bgra: &[u8]) {
for (px, m) in rgba.chunks_exact_mut(4).zip(mask_bgra.chunks_exact(4)) {
px[3] = if m[0] != 0 { 0 } else { 0xFF };
/// Alpha-less colour cursor: the AND mask plus the colour bitmap as XOR, same four states as
/// [`mono_planes_to_rgba`]. A non-zero RGB with AND=1 is invert — which treating AND=1 as
/// "always transparent" would have dropped, vanishing the I-beam.
///
/// `(false, true)` keeps the colour (a painted glyph), unlike the monochrome table which can
/// only emit white.
fn masked_color_to_rgba(color_rgba: &[u8], mask_bgra: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut rgba = vec![0u8; w * h * 4];
let mut invert = vec![false; w * h];
for i in 0..w * h {
// Either source running short leaves the pixel TRANSPARENT — the quiet failure. Reading a
// missing mask byte as AND=0 instead would land in the opaque arms and paint a black box
// over the pointer, which is the loudest way to be wrong. `convert` already requires the
// mask to cover the colour bitmap, so this is only the belt.
let (Some(&m), Some(c)) = (mask_bgra.get(i * 4), color_rgba.get(i * 4..i * 4 + 3)) else {
continue;
};
let and = m != 0;
let xor = c[0] != 0 || c[1] != 0 || c[2] != 0;
let px = &mut rgba[i * 4..i * 4 + 4];
match (and, xor) {
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
(false, true) => px.copy_from_slice(&[c[0], c[1], c[2], 0xFF]),
(true, false) => {} // transparent (already zeroed)
(true, true) => {
px.copy_from_slice(&[0, 0, 0, 0xFF]);
invert[i] = true;
}
}
}
grow_invert_outline(&mut rgba, &invert, w, h);
rgba
}
/// The monochrome-cursor truth table, plus the white outline that makes an INVERT region legible.
@@ -645,6 +675,13 @@ fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -
}
}
}
grow_invert_outline(&mut rgba, &invert, w, h);
rgba
}
/// Turn every TRANSPARENT 8-neighbour of an invert pixel opaque white. Invert itself stays
/// opaque black. Shared by the monochrome and masked-color converters.
fn grow_invert_outline(rgba: &mut [u8], invert: &[bool], w: usize, h: usize) {
for y in 0..h as i32 {
for x in 0..w as i32 {
if !invert[(y * w as i32 + x) as usize] {
@@ -662,7 +699,6 @@ fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -
}
}
}
rgba
}
#[cfg(test)]
@@ -771,27 +807,70 @@ mod tests {
assert!(alpha_is_empty(&[]), "no pixels ⇒ vacuously empty");
}
/// Mask WHITE (AND bit 1) = transparent, black = opaque — and the colour bytes are untouched.
// ---- masked-color (colour bitmap as XOR) ------------------------------------------------
/// The four-state table, with colour standing in for XOR. Pixel 3 is the I-beam case: AND=1
/// and a non-zero colour pixel is invert, not transparent — the old AND-as-alpha helper read
/// AND=1 as "always transparent" and dropped it, which is how the text cursor vanished on a
/// Windows host.
#[test]
fn the_and_mask_supplies_alpha_for_an_alpha_less_cursor() {
let mut rgba = vec![
10, 20, 30, 0, // pixel 0
40, 50, 60, 0, // pixel 1
fn a_masked_color_invert_pixel_is_not_transparent() {
// (0,0) black (0,1) red (1,0) transparent (1,1) invert
let color = vec![
0, 0, 0, 0, //
0xCC, 0, 0, 0, //
0, 0, 0, 0, //
0xFF, 0xFF, 0xFF, 0, //
];
let mask = plane(&[1, 0]); // pixel 0 masked out, pixel 1 kept
apply_and_mask_alpha(&mut rgba, &mask);
assert_eq!(px(&rgba, 0), [10, 20, 30, 0], "masked ⇒ transparent");
assert_eq!(px(&rgba, 1), [40, 50, 60, 0xFF], "unmasked ⇒ opaque");
let mask = plane(&[0, 0, 1, 1]);
let out = masked_color_to_rgba(&color, &mask, 4, 1);
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 colour=0 ⇒ black");
assert_eq!(
px(&out, 1),
[0xCC, 0, 0, 0xFF],
"AND=0 colour ⇒ opaque colour"
);
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
// so the outline claims it — same as the monochrome table.
assert_eq!(
px(&out, 2),
OPAQUE_WHITE,
"outline grows into adjacent transparency"
);
assert_eq!(
px(&out, 3),
OPAQUE_BLACK,
"AND=1 colour≠0 ⇒ invert, not drop"
);
}
/// A mask with FEWER pixels than the colour bitmap must not panic — `zip` stops at the shorter
/// side, leaving the tail at whatever alpha it had (the caller has already required
/// `mask.h >= color.h`, so this is the belt).
/// AND=1 and a zero colour pixel stays transparent when nothing invert-neighbours it.
#[test]
fn a_short_mask_does_not_panic() {
let mut rgba = vec![1, 2, 3, 0, 4, 5, 6, 0, 7, 8, 9, 0];
apply_and_mask_alpha(&mut rgba, &plane(&[0]));
assert_eq!(px(&rgba, 0), [1, 2, 3, 0xFF]);
assert_eq!(px(&rgba, 1), [4, 5, 6, 0]);
fn a_masked_color_transparent_pixel_stays_transparent() {
let color = vec![0u8; 16];
let mask = plane(&[1, 1, 1, 1]);
let out = masked_color_to_rgba(&color, &mask, 4, 1);
for i in 0..4 {
assert_eq!(px(&out, i), TRANSPARENT, "pixel {i}");
}
}
/// Either source running short must not panic AND must leave the uncovered tail transparent —
/// reading a missing byte as a zero would land in the opaque arms and paint a black box over
/// the pointer. `convert` already requires `mask.h >= color.h`, so this is only the belt.
#[test]
fn a_masked_color_short_source_leaves_the_tail_transparent() {
let color = vec![0xFF; 16]; // four white pixels...
let out = masked_color_to_rgba(&color, &plane(&[0]), 4, 1); // ...but one mask pixel
assert_eq!(px(&out, 0), OPAQUE_WHITE, "the covered pixel converts");
for i in 1..4 {
assert_eq!(px(&out, i), TRANSPARENT, "mask ran short at pixel {i}");
}
let out = masked_color_to_rgba(&color[..4], &plane(&[0, 0, 0, 0]), 4, 1);
assert_eq!(px(&out, 0), OPAQUE_WHITE, "the covered pixel converts");
for i in 1..4 {
assert_eq!(px(&out, i), TRANSPARENT, "colour ran short at pixel {i}");
}
}
}