Render "invert video" cursors visibly
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user