Render "invert video" cursors visibly

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-09-02 11:16:36 +02:00
parent bcb81b129d
commit 85415fd7c7
2 changed files with 173 additions and 31 deletions
+59 -31
View File
@@ -133,6 +133,26 @@ class CurDecoder {
throw const FormatException("cursor pixel data bounds truncated");
}
// Classic "invert video" cursors encode their whole shape in the XOR image
// while the AND mask stays solid: Windows paints each non-empty XOR pixel
// by inverting the screen underneath, which keeps the cursor visible on
// any backdrop. Static raster cursors can't invert the desktop, so those
// pixels are approximated with opaque black.
var isInvertVideo = bpp <= 8;
if (isInvertVideo) {
outer:
for (var row = 0; row < height; row++) {
final srcRow = height - 1 - row;
final base = offset + andOffset + srcRow * andStride;
for (var x = 0; x < width; x++) {
if (((bytes[base + (x >> 3)] >> (7 - (x & 7))) & 1) == 0) {
isInvertVideo = false;
break outer;
}
}
}
}
final rgba = Uint8List(width * height * 4);
for (var row = 0; row < height; row++) {
final srcRow = height - 1 - row; // DIB rows are bottom-up
@@ -142,15 +162,48 @@ class CurDecoder {
for (var x = 0; x < width; x++) {
final dest = (row * width + x) * 4;
final andByte = bytes[andBase + (x >> 3)];
final isTransparent = ((andByte >> (7 - (x & 7))) & 1) != 0;
int? index;
if (bpp == 1) {
final byte = bytes[xorBase + (x >> 3)];
index = (byte >> (7 - (x & 7))) & 1;
} else if (bpp == 4) {
final byte = bytes[xorBase + (x >> 1)];
index = x.isEven ? (byte >> 4) & 0xF : byte & 0xF;
} else if (bpp == 8) {
index = bytes[xorBase + x];
}
if (isTransparent) {
// Masked out: transparent. (RGBA array is zero-initialized by
// default.)
if (index != null) {
if (isInvertVideo) {
// The glyph is wherever the XOR image is non-empty; its pixels can
// only be approximated, so they are drawn as opaque black.
if (index != 0) rgba[dest + 3] = 255;
continue;
}
final andByte = bytes[andBase + (x >> 3)];
final andBit = ((andByte >> (7 - (x & 7))) & 1) != 0;
if (andBit) continue; // Masked out: transparent.
final paletteIndex = index * 4;
// Gracefully handle malformed indices that overrun the provided
// palette.
if (paletteIndex + 3 < paletteCount * 4) {
final p = offset + paletteOffset + paletteIndex;
rgba[dest] = bytes[p + 2]; // R
rgba[dest + 1] = bytes[p + 1]; // G
rgba[dest + 2] = bytes[p]; // B
rgba[dest + 3] = 255; // A
} else {
rgba[dest + 3] = 255; // Draw fallback visible pixel (Black).
}
continue;
}
final andByte = bytes[andBase + (x >> 3)];
final andBit = ((andByte >> (7 - (x & 7))) & 1) != 0;
if (andBit) continue; // Masked out: transparent.
if (bpp == 32) {
final p = xorBase + x * 4;
rgba[dest] = bytes[p + 2]; // R
@@ -173,32 +226,7 @@ class CurDecoder {
continue;
}
int index;
if (bpp == 1) {
final byte = bytes[xorBase + (x >> 3)];
index = (byte >> (7 - (x & 7))) & 1;
} else if (bpp == 4) {
final byte = bytes[xorBase + (x >> 1)];
index = x.isEven ? (byte >> 4) & 0xF : byte & 0xF;
} else if (bpp == 8) {
index = bytes[xorBase + x];
} else {
throw FormatException("unsupported bpp: $bpp");
}
final paletteIndex = index * 4;
// Gracefully handle malformed indices that overrun the provided
// palette.
if (paletteIndex + 3 < paletteCount * 4) {
final p = offset + paletteOffset + paletteIndex;
rgba[dest] = bytes[p + 2]; // R
rgba[dest + 1] = bytes[p + 1]; // G
rgba[dest + 2] = bytes[p]; // B
rgba[dest + 3] = 255; // A
} else {
rgba[dest + 3] = 255; // Draw fallback visible pixel (Black).
}
throw FormatException("unsupported bpp: $bpp");
}
}
+114
View File
@@ -0,0 +1,114 @@
import "dart:io";
import "dart:typed_data";
import "dart:ui" as ui;
import "package:custom_cursor/custom_cursor.dart";
import "package:flutter_test/flutter_test.dart";
/// Regression coverage for the curated `broken-cursors` corpus.
///
/// The remaining fixtures are classic monochrome Windows cursors whose shapes
/// were encoded as "screen invert" pixels (an all-set AND mask with the XOR
/// mask carving out the glyph). The decoder used to drop those pixels,
/// producing fully transparent (invisible) cursors; they become visible once
/// the invert pixels are rendered opaque.
void main() {
Uint8List bytesOf(String path) {
final file = File(path);
return file.existsSync()
? file.readAsBytesSync()
: fail("missing fixture $path");
}
Future<int> opaqueCount(ui.Image image) async {
final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
final bytes = data!.buffer.asUint8List();
var opaque = 0;
for (var i = 0; i < bytes.length; i += 4) {
if (bytes[i + 3] != 0) opaque++;
}
return opaque;
}
Cursor firstFrame(CursorAsset asset) => switch (asset) {
StaticCursor(:final cursor) => cursor,
AnimatedCursorAsset(:final cursor) => cursor.frames.first,
};
group("monochrome screen-invert cursors decode visibly", () {
const fixtures = [
"beam_l.cur",
"beam_m.cur",
"Beam.cur",
"cross_l.cur",
"cross_m.cur",
"cross-xp.cur",
"lcross.cur",
"libeam.cur",
"Cursor_4.cur",
"default_ibeam.cur",
];
for (final name in fixtures) {
test(name, () async {
final asset = await decodeCursorAsset(
bytesOf("broken-cursors/$name"),
options: const DecodeOptions(targetSize: 32),
);
final cursor = firstFrame(asset);
expect(cursor.image.width, 32);
expect(cursor.image.height, 32);
expect(await opaqueCount(cursor.image), greaterThan(0));
});
}
});
test("Cursor_4 keeps its hotspot and renders a black beam", () async {
final asset = await decodeCursorAsset(
bytesOf("broken-cursors/Cursor_4.cur"),
);
final cursor = firstFrame(asset);
expect(cursor.hotspotX, 10);
expect(cursor.hotspotY, 10);
final data = await cursor.image.toByteData(
format: ui.ImageByteFormat.rawRgba,
);
final bytes = data!.buffer.asUint8List();
final width = cursor.image.width;
expect(width, 32);
final height = cursor.image.height;
final opaquePixels = <int>[];
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
final p = (y * width + x) * 4;
if (bytes[p + 3] != 0) opaquePixels.add(p);
}
}
expect(opaquePixels, isNotEmpty);
expect(
opaquePixels.every(
(p) => bytes[p] == 0 && bytes[p + 1] == 0 && bytes[p + 2] == 0,
),
isTrue,
);
});
test("reference cursors still decode", () async {
for (final name in ["normal", "move", "resize"]) {
final asset = await decodeCursorAsset(
bytesOf("example/assets/cursors/$name.cur"),
);
expect(await opaqueCount(firstFrame(asset).image), greaterThan(0));
}
});
test("every remaining binary fixture decodes without errors", () async {
for (final file in Directory(
"broken-cursors",
).listSync().whereType<File>()) {
if (!file.path.endsWith(".cur")) continue;
final asset = await decodeCursorAsset(file.readAsBytesSync());
expect(asset, isNotNull);
}
});
}