feat: add custom cursor support for classic skins
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -46,10 +46,24 @@ items `[x]` as done.
|
||||
|
||||
### A3. Cursors (task 4)
|
||||
|
||||
- `ClassicSkin` exposes cursor bitmaps (`cursor.bmp`/`.cur`) + hotspot (default
|
||||
skin included).
|
||||
- Native `NSCursor` from skin bitmap via the `winamp` channel; applied with
|
||||
`MouseRegion` over the windows.
|
||||
- [x] `CurDecoder` in the package (`cur_decoder.dart`): decodes Windows `.cur`
|
||||
resources (1/4/8/32 bpp, AND-mask transparency, hotspot) into
|
||||
`SkinCursor { ui.Image, hotspotX, hotspotY }`.
|
||||
- [x] `ClassicSkin` exposes `Map<String, SkinCursor> cursors` keyed by the
|
||||
`.cur` file stem; the base skin bundles the original
|
||||
`normal.cur`/`move.cur`/`resize.cur` (copied from `original/Src/Winamp/
|
||||
resource/`); `loadBaseSkin` + `_buildSkin` decode them, so imported skins
|
||||
can override cursors and fall back to base otherwise.
|
||||
- [x] App: `registerSkinCursors`/`setSkinCursor` over the `winamp` channel;
|
||||
AppDelegate builds `NSCursor` from the PNG+hotspot and `setCursor`
|
||||
switches it. `SkinCursorsGate` (MouseRegion) applies the skin's normal
|
||||
cursor on enter and restores the arrow on exit, wrapping the main, EQ,
|
||||
and PL windows.
|
||||
- [x] Note: sub-windows run their own Flutter engines and their `winamp`
|
||||
channel calls can't reach the AppDelegate handler, so custom cursors are
|
||||
applied on the main window; EQ/PL keep the system arrow for now.
|
||||
- [x] Verify: package tests (14 incl. `cur_decoder_test.dart`), app analyze
|
||||
clean, `flutter build macos --debug` ok, app launches with no errors.
|
||||
|
||||
## Phase B — Docking & window management (new `lib/src/docking/`)
|
||||
|
||||
|
||||
+25
-21
@@ -11,6 +11,7 @@ import "package:window_manager/window_manager.dart";
|
||||
import "src/common_widgets.dart";
|
||||
import "src/main_window/main_window.dart";
|
||||
import "src/player/player_state.dart";
|
||||
import "src/skin/skin_cursors.dart";
|
||||
import "src/skin/skin_manager.dart";
|
||||
import "src/windows/sub_window_app.dart";
|
||||
import "src/windows/sub_window_channel.dart";
|
||||
@@ -19,8 +20,6 @@ import "src/windows/window_args.dart";
|
||||
const Size _mainSize = Size(275, 116);
|
||||
const Size _shadeSize = Size(275, 14);
|
||||
|
||||
const _winampChannel = MethodChannel("winamp");
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -164,7 +163,7 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
_skins.addListener(_onSkinChanged);
|
||||
|
||||
// Set up method channel for macOS menu actions
|
||||
_winampChannel.setMethodCallHandler(_handleMethodCall);
|
||||
winampChannel.setMethodCallHandler(_handleMethodCall);
|
||||
|
||||
// Sub-windows report shade toggles and close requests back here.
|
||||
unawaited(
|
||||
@@ -269,6 +268,8 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
final name = _skins.active.name;
|
||||
if (name == _wasSkinName) return;
|
||||
_wasSkinName = name;
|
||||
final skin = _skins.skin;
|
||||
if (skin != null) unawaited(registerSkinCursors(skin));
|
||||
_eqController?.invokeMethod("setSkin", name);
|
||||
_plController?.invokeMethod("setSkin", name);
|
||||
}
|
||||
@@ -379,15 +380,15 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
/// what the in-window buttons do. Best-effort; native may not be up yet.
|
||||
Future<void> _syncMenuToNative() async {
|
||||
try {
|
||||
await _winampChannel.invokeMethod("setAlwaysOnTop", {
|
||||
await winampChannel.invokeMethod("setAlwaysOnTop", {
|
||||
"isAlwaysOnTop": _player.alwaysOnTop,
|
||||
});
|
||||
await _winampChannel.invokeMethod("setWindowShade", {
|
||||
await winampChannel.invokeMethod("setWindowShade", {
|
||||
"on": _player.windowShade,
|
||||
});
|
||||
await _winampChannel.invokeMethod("setEqOpen", {"open": _player.eqOpen});
|
||||
await _winampChannel.invokeMethod("setPlOpen", {"open": _player.plOpen});
|
||||
await _winampChannel.invokeMethod("setUiScale", {
|
||||
await winampChannel.invokeMethod("setEqOpen", {"open": _player.eqOpen});
|
||||
await winampChannel.invokeMethod("setPlOpen", {"open": _player.plOpen});
|
||||
await winampChannel.invokeMethod("setUiScale", {
|
||||
"scale": _player.uiScale,
|
||||
});
|
||||
} on PlatformException {
|
||||
@@ -416,19 +417,22 @@ class _WinampRootState extends State<WinampRoot> {
|
||||
final height = (_player.windowShade ? _shadeSize : _mainSize).height;
|
||||
return ColoredBox(
|
||||
color: const Color(0xFF000000),
|
||||
child: DragSurface(
|
||||
draggable: _windowReady,
|
||||
onWindowShadeToggle: _player.toggleWindowShade,
|
||||
child: SizedBox(
|
||||
width: _mainSize.width * scale,
|
||||
height: height * scale,
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
alignment: Alignment.topLeft,
|
||||
filterQuality: FilterQuality.none,
|
||||
child: MainWindowView(
|
||||
skin: skin,
|
||||
player: _player,
|
||||
child: SkinCursorsGate(
|
||||
skin: skin,
|
||||
child: DragSurface(
|
||||
draggable: _windowReady,
|
||||
onWindowShadeToggle: _player.toggleWindowShade,
|
||||
child: SizedBox(
|
||||
width: _mainSize.width * scale,
|
||||
height: height * scale,
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
alignment: Alignment.topLeft,
|
||||
filterQuality: FilterQuality.none,
|
||||
child: MainWindowView(
|
||||
skin: skin,
|
||||
player: _player,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import "package:flutter/material.dart";
|
||||
|
||||
import "../common_widgets.dart";
|
||||
import "../player/player_state.dart";
|
||||
import "../skin/skin_cursors.dart";
|
||||
import "../windows/sub_window_channel.dart";
|
||||
|
||||
const Size _eqMainSize = Size(275, 116);
|
||||
@@ -37,10 +38,13 @@ class EqWindow extends StatelessWidget {
|
||||
if (!snapshot.hasData) {
|
||||
return const ColoredBox(color: Colors.black);
|
||||
}
|
||||
return DragSurface(
|
||||
draggable: true,
|
||||
onWindowShadeToggle: player.toggleEqShade,
|
||||
child: _EqWindowView(skin: snapshot.data!, player: player),
|
||||
return SkinCursorsGate(
|
||||
skin: snapshot.data!,
|
||||
child: DragSurface(
|
||||
draggable: true,
|
||||
onWindowShadeToggle: player.toggleEqShade,
|
||||
child: _EqWindowView(skin: snapshot.data!, player: player),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import "package:flutter/material.dart";
|
||||
|
||||
import "../common_widgets.dart";
|
||||
import "../player/player_state.dart";
|
||||
import "../skin/skin_cursors.dart";
|
||||
import "../windows/sub_window_channel.dart";
|
||||
|
||||
const Size _plMainSize = Size(280, 290);
|
||||
@@ -168,10 +169,13 @@ class PlWindow extends StatelessWidget {
|
||||
if (!snapshot.hasData) {
|
||||
return const ColoredBox(color: Colors.black);
|
||||
}
|
||||
return DragSurface(
|
||||
draggable: true,
|
||||
onWindowShadeToggle: player.togglePlShade,
|
||||
child: _PlWindowView(skin: snapshot.data!, player: player),
|
||||
return SkinCursorsGate(
|
||||
skin: snapshot.data!,
|
||||
child: DragSurface(
|
||||
draggable: true,
|
||||
onWindowShadeToggle: player.togglePlShade,
|
||||
child: _PlWindowView(skin: snapshot.data!, player: player),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import "dart:async";
|
||||
import "dart:ui" as ui;
|
||||
|
||||
import "package:dartamp_skin/dartamp_skin.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
|
||||
/// The platform channel to the macOS app delegate.
|
||||
const winampChannel = MethodChannel("winamp");
|
||||
|
||||
/// Registers every custom cursor of [skin] with the native side, keyed by
|
||||
/// name, so hover regions can switch to them. Best-effort: failures are
|
||||
/// ignored because the native side may not be ready yet.
|
||||
Future<void> registerSkinCursors(ClassicSkin skin) async {
|
||||
for (final entry in skin.cursors.entries) {
|
||||
final png = await _cursorPng(entry.value.image);
|
||||
try {
|
||||
await winampChannel.invokeMethod(
|
||||
"registerCursor",
|
||||
{
|
||||
"name": entry.key,
|
||||
"png": png,
|
||||
"hotspotX": entry.value.hotspotX,
|
||||
"hotspotY": entry.value.hotspotY,
|
||||
},
|
||||
);
|
||||
} on PlatformException {
|
||||
// ignore: best-effort
|
||||
} on MissingPluginException {
|
||||
// ignore: best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List> _cursorPng(ui.Image image) async {
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return data!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// Switches the platform cursor to the registered skin cursor [name], or back
|
||||
/// to the system arrow when the name is unknown. Best-effort.
|
||||
Future<void> setSkinCursor(String name) async {
|
||||
try {
|
||||
await winampChannel.invokeMethod("setCursor", {"name": name});
|
||||
} on PlatformException {
|
||||
// ignore: best-effort
|
||||
} on MissingPluginException {
|
||||
// ignore: best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers [skin]'s cursors once, then wraps [child] so hovering over it
|
||||
/// shows the skin's default cursor and leaving restores the system arrow.
|
||||
class SkinCursorsGate extends StatefulWidget {
|
||||
const SkinCursorsGate({required this.skin, required this.child, super.key});
|
||||
|
||||
final ClassicSkin skin;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<SkinCursorsGate> createState() => _SkinCursorsGateState();
|
||||
}
|
||||
|
||||
class _SkinCursorsGateState extends State<SkinCursorsGate> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(registerSkinCursors(widget.skin));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SkinCursorsGate oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.skin != widget.skin) {
|
||||
unawaited(registerSkinCursors(widget.skin));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCustom = widget.skin.cursors.containsKey("normal");
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setSkinCursor(hasCustom ? "normal" : "arrow"),
|
||||
onExit: (_) => setSkinCursor("arrow"),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ class AppDelegate: FlutterAppDelegate {
|
||||
|
||||
private var _winampChannel: FlutterMethodChannel?
|
||||
|
||||
/// Custom cursors registered by the Dart side, keyed by skin cursor name.
|
||||
private var cursors: [String: NSCursor] = [:]
|
||||
|
||||
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "setAlwaysOnTop":
|
||||
@@ -81,6 +84,25 @@ class AppDelegate: FlutterAppDelegate {
|
||||
setUiScaleMenuState(scale)
|
||||
}
|
||||
result(nil)
|
||||
case "registerCursor":
|
||||
if let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let pngData = (args["png"] as? FlutterStandardTypedData)?.data,
|
||||
let image = NSImage(data: pngData) {
|
||||
let hotX = args["hotspotX"] as? Int ?? 0
|
||||
let hotY = args["hotspotY"] as? Int ?? 0
|
||||
cursors[name] = NSCursor(image: image, hotSpot: NSPoint(x: hotX, y: hotY))
|
||||
}
|
||||
result(nil)
|
||||
case "setCursor":
|
||||
if let args = call.arguments as? [String: Any], let name = args["name"] as? String {
|
||||
if let cursor = cursors[name] {
|
||||
cursor.set()
|
||||
} else {
|
||||
NSCursor.arrow.set()
|
||||
}
|
||||
}
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 766 B |
Binary file not shown.
|
After Width: | Height: | Size: 766 B |
Binary file not shown.
|
After Width: | Height: | Size: 766 B |
@@ -8,6 +8,7 @@ library;
|
||||
|
||||
export "src/bmp_decoder.dart";
|
||||
export "src/classic_skin.dart";
|
||||
export "src/cur_decoder.dart";
|
||||
export "src/skin_image.dart";
|
||||
export "src/skin_info.dart";
|
||||
export "src/skin_loader.dart";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "dart:ui" as ui;
|
||||
|
||||
import "cur_decoder.dart";
|
||||
|
||||
/// Playlist editor text colors parsed from `pledit.txt`.
|
||||
class PlEditConfig {
|
||||
const PlEditConfig({
|
||||
@@ -74,6 +76,7 @@ class ClassicSkin {
|
||||
ClassicSkin({
|
||||
required this.name,
|
||||
required this.images,
|
||||
this.cursors = const {},
|
||||
this.regions = const {},
|
||||
this.regionPaths = const {},
|
||||
this.pledit = const PlEditConfig(),
|
||||
@@ -86,6 +89,10 @@ class ClassicSkin {
|
||||
/// Bitmaps keyed by lower-cased file name (e.g. `main.bmp`).
|
||||
final Map<String, ui.Image> images;
|
||||
|
||||
/// Custom cursors keyed by the `.cur` file stem (e.g. `normal` from
|
||||
/// `normal.cur`). Skins that ship no cursor fall back to the base skin's.
|
||||
final Map<String, SkinCursor> cursors;
|
||||
|
||||
/// Raw polygon regions from `region.txt`.
|
||||
final Map<String, SkinRegion> regions;
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import "dart:async";
|
||||
import "dart:typed_data";
|
||||
import "dart:ui" as ui;
|
||||
|
||||
/// A custom cursor decoded from a Windows `.cur` resource.
|
||||
class SkinCursor {
|
||||
const SkinCursor({
|
||||
required this.image,
|
||||
required this.hotspotX,
|
||||
required this.hotspotY,
|
||||
});
|
||||
|
||||
/// The rendered cursor in RGBA.
|
||||
final ui.Image image;
|
||||
|
||||
/// The cursor's hotspot (the pixel that lands on the pointer) in image
|
||||
/// pixels, clamped to the image bounds.
|
||||
final int hotspotX;
|
||||
final int hotspotY;
|
||||
}
|
||||
|
||||
/// Decodes Windows cursor (`.cur`) resources, used for the custom cursors
|
||||
/// classic skins bundle alongside their bitmaps.
|
||||
class CurDecoder {
|
||||
CurDecoder._();
|
||||
|
||||
/// Decodes the first image in [bytes]. Throws a [FormatException] on
|
||||
/// malformed input.
|
||||
static Future<SkinCursor> decode(Uint8List bytes) async {
|
||||
final view = ByteData.sublistView(bytes);
|
||||
if (bytes.length < 6) {
|
||||
throw const FormatException("cursor file too short");
|
||||
}
|
||||
if (view.getUint16(0, Endian.little) != 0) {
|
||||
throw const FormatException("bad cursor reserved field");
|
||||
}
|
||||
if (view.getUint16(2, Endian.little) != 2) {
|
||||
throw const FormatException("not a cursor file (type != 2)");
|
||||
}
|
||||
final count = view.getUint16(4, Endian.little);
|
||||
if (count == 0) throw const FormatException("cursor has no images");
|
||||
|
||||
const entry = 6;
|
||||
final hotspotX = view.getUint16(entry + 4, Endian.little);
|
||||
final hotspotY = view.getUint16(entry + 6, Endian.little);
|
||||
final size = view.getUint32(entry + 8, Endian.little);
|
||||
final offset = view.getUint32(entry + 12, Endian.little);
|
||||
if (offset + size > bytes.length) {
|
||||
throw const FormatException("cursor bitmap data truncated");
|
||||
}
|
||||
|
||||
final dib = ByteData.sublistView(bytes, offset, offset + size);
|
||||
if (size < 40) {
|
||||
throw const FormatException("cursor DIB header too small");
|
||||
}
|
||||
final width = dib.getInt32(4, Endian.little);
|
||||
final imageHeight = dib.getInt32(8, Endian.little);
|
||||
final planes = dib.getUint16(12, Endian.little);
|
||||
final bpp = dib.getUint16(14, Endian.little);
|
||||
if (planes != 1) throw const FormatException("unsupported cursor planes");
|
||||
if (width <= 0 || width > 256)
|
||||
throw const FormatException("bad cursor width");
|
||||
if (imageHeight <= 0 || imageHeight > 512) {
|
||||
throw const FormatException("bad cursor height");
|
||||
}
|
||||
|
||||
// The DIB height doubles the XOR bitmap with the AND mask underneath.
|
||||
final height = imageHeight ~/ 2;
|
||||
final paletteCount = bpp <= 8 ? (1 << bpp) : 0;
|
||||
const paletteOffset = 40;
|
||||
final xorOffset = paletteOffset + paletteCount * 4;
|
||||
final xorStride = ((width * bpp + 31) >> 5) << 2;
|
||||
final andStride = ((width + 31) >> 5) << 2;
|
||||
final andOffset = xorOffset + xorStride * height;
|
||||
|
||||
final rgba = Uint8List(width * height * 4);
|
||||
for (var row = 0; row < height; row++) {
|
||||
final srcRow = height - 1 - row; // DIB rows are bottom-up
|
||||
final xorBase = offset + xorOffset + srcRow * xorStride;
|
||||
final andBase = offset + andOffset + srcRow * andStride;
|
||||
for (var x = 0; x < width; x++) {
|
||||
final dest = (row * width + x) * 4;
|
||||
final andByte = bytes[andBase + x >> 3];
|
||||
if (((andByte >> (7 - (x & 7))) & 1) != 0) {
|
||||
// Masked out: transparent.
|
||||
rgba[dest + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
if (bpp == 32) {
|
||||
final p = xorBase + x * 4;
|
||||
rgba[dest] = bytes[p + 2];
|
||||
rgba[dest + 1] = bytes[p + 1];
|
||||
rgba[dest + 2] = bytes[p];
|
||||
rgba[dest + 3] = bytes[p + 3];
|
||||
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 {
|
||||
index = bytes[xorBase + x];
|
||||
}
|
||||
final paletteIndex = (index * 4).clamp(0, paletteCount * 4 - 4);
|
||||
final p = offset + paletteOffset + paletteIndex;
|
||||
rgba[dest] = bytes[p + 2];
|
||||
rgba[dest + 1] = bytes[p + 1];
|
||||
rgba[dest + 2] = bytes[p];
|
||||
rgba[dest + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
final image = await _rgbaToUiImage(width, height, rgba);
|
||||
return SkinCursor(
|
||||
image: image,
|
||||
hotspotX: hotspotX.clamp(0, width - 1),
|
||||
hotspotY: hotspotY.clamp(0, height - 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ui.Image> _rgbaToUiImage(int width, int height, Uint8List rgba) {
|
||||
final completer = Completer<ui.Image>();
|
||||
ui.decodeImageFromPixels(
|
||||
rgba,
|
||||
width,
|
||||
height,
|
||||
ui.PixelFormat.rgba8888,
|
||||
completer.complete,
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import "package:flutter/services.dart" show rootBundle;
|
||||
|
||||
import "bmp_decoder.dart";
|
||||
import "classic_skin.dart";
|
||||
import "cur_decoder.dart";
|
||||
|
||||
/// Names of the bitmaps that make up a classic skin, mirroring the file
|
||||
/// list the original loader scans for (`draw_LBitmap` in `draw.cpp`).
|
||||
@@ -53,6 +54,10 @@ const bundledSkinFiles = <String>[
|
||||
"pledit.bmp",
|
||||
];
|
||||
|
||||
/// Cursors bundled with the app (the classic Winamp arrow, move, and resize
|
||||
/// cursors from the original `resource/` folder).
|
||||
const bundledCursorFiles = <String>["normal.cur", "move.cur", "resize.cur"];
|
||||
|
||||
/// Converts a decoded BMP into a GPU [ui.Image].
|
||||
Future<ui.Image> bmpToUiImage(BmpImage bmp) {
|
||||
final completer = Completer<ui.Image>();
|
||||
@@ -79,7 +84,19 @@ Future<ClassicSkin> loadBaseSkin() async {
|
||||
);
|
||||
images[name.toLowerCase()] = await bmpToUiImage(decodeBmp(bytes));
|
||||
}
|
||||
return ClassicSkin(name: "Base", images: images);
|
||||
final cursors = <String, SkinCursor>{};
|
||||
for (final name in bundledCursorFiles) {
|
||||
final data = await rootBundle.load(
|
||||
"packages/dartamp_skin/assets/skins/base/$name",
|
||||
);
|
||||
final bytes = data.buffer.asUint8List(
|
||||
data.offsetInBytes,
|
||||
data.lengthInBytes,
|
||||
);
|
||||
final stem = name.toLowerCase().replaceAll(".cur", "");
|
||||
cursors[stem] = await CurDecoder.decode(bytes);
|
||||
}
|
||||
return ClassicSkin(name: "Base", images: images, cursors: cursors);
|
||||
}
|
||||
|
||||
/// Loads a classic skin (`.wsz` / `.zip` archive) on top of [base], so any
|
||||
@@ -154,9 +171,23 @@ Future<ClassicSkin> _buildSkin(
|
||||
if (parsed != null) visColors = parsed;
|
||||
}
|
||||
|
||||
final cursors = <String, SkinCursor>{...base.cursors};
|
||||
for (final entry in files.entries) {
|
||||
if (!entry.key.endsWith(".cur")) continue;
|
||||
final stem = entry.key.substring(0, entry.key.length - 4);
|
||||
try {
|
||||
cursors[stem] = await CurDecoder.decode(
|
||||
Uint8List.fromList(entry.value),
|
||||
);
|
||||
} catch (_) {
|
||||
// keep the base cursor
|
||||
}
|
||||
}
|
||||
|
||||
return ClassicSkin(
|
||||
name: name ?? "Skin",
|
||||
images: images,
|
||||
cursors: cursors,
|
||||
regions: regions,
|
||||
regionPaths: {for (final e in regions.entries) e.key: e.value.toPath()!},
|
||||
pledit: pledit,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import "dart:io";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:dartamp_skin/dartamp_skin.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test("decodes the bundled normal cursor", () async {
|
||||
final bytes = File("assets/skins/base/normal.cur").readAsBytesSync();
|
||||
final cursor = await CurDecoder.decode(bytes);
|
||||
expect(cursor.image.width, 32);
|
||||
expect(cursor.image.height, 32);
|
||||
// The classic Winamp arrow's hotspot sits at the tip, near the top-left.
|
||||
expect(cursor.hotspotX, 0);
|
||||
expect(cursor.hotspotY, 0);
|
||||
final data = await cursor.image.toByteData();
|
||||
expect(data, isNotNull);
|
||||
// The arrow is not fully transparent: at least one opaque pixel exists.
|
||||
var opaque = 0;
|
||||
for (var i = 3; i < data!.lengthInBytes; i += 4) {
|
||||
if (data.getUint8(i) != 0) opaque++;
|
||||
}
|
||||
expect(opaque, greaterThan(0));
|
||||
});
|
||||
|
||||
test("rejects non-cursor files", () async {
|
||||
await expectLater(
|
||||
CurDecoder.decode(Uint8List.fromList([0x42, 0x4D, 0, 0])),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test("base skin bundles the classic cursors", () async {
|
||||
final skin = await loadBaseSkin();
|
||||
expect(skin.cursors.keys, containsAll(["normal", "move", "resize"]));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user