e40018ab4e
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
303 lines
11 KiB
Dart
303 lines
11 KiB
Dart
import "dart:async";
|
|
import "dart:ui" as ui;
|
|
|
|
import "package:flutter/services.dart";
|
|
import "package:flutter/widgets.dart";
|
|
import "package:material_ui/material_ui.dart";
|
|
|
|
import "cursor.dart";
|
|
import "cursor_asset.dart";
|
|
import "cursor_scaler.dart";
|
|
|
|
abstract class CursorHandler {
|
|
/// The platform channel used to communicate cursor operations to the native
|
|
/// side.
|
|
static const _channel = MethodChannel("custom_cursor");
|
|
|
|
/// Display-name suffix for the right-to-left (mirrored) variant of a cursor.
|
|
static const String _rtlSuffix = ">rtl";
|
|
|
|
static final Map<String, _AnimatedRegistration> _animated = {};
|
|
static Timer? _animationTimer;
|
|
static ValueChanged<double>? _scaleObserver;
|
|
static ValueChanged<TextDirection>? _directionObserver;
|
|
static bool _handlerInstalled = false;
|
|
static bool _scaleListening = false;
|
|
static bool _directionListening = false;
|
|
|
|
/// The currently active asset name, so a live OS-direction change can re-apply
|
|
/// the visible cursor.
|
|
static String? _activeAssetName;
|
|
|
|
/// Reads the current operating-system pointer-size multiplier (macOS
|
|
/// accessibility setting), defaulting to `1.0` when unavailable or on
|
|
/// unsupported platforms.
|
|
static Future<double> systemCursorScale() async {
|
|
try {
|
|
final value = await _channel.invokeMethod<double>("systemCursorScale");
|
|
return value ?? 1.0;
|
|
} on PlatformException {
|
|
return 1.0;
|
|
} on MissingPluginException {
|
|
return 1.0;
|
|
}
|
|
}
|
|
|
|
/// Whether the platform should be queried for the OS direction. Used so tests
|
|
/// and non-macOS platforms fall back gracefully.
|
|
static bool _queryOsDirection = true;
|
|
|
|
/// Reads the current operating-system layout direction (macOS), defaulting to
|
|
/// left-to-right when unavailable or on unsupported platforms.
|
|
static Future<TextDirection> systemTextDirection() async {
|
|
if (!_queryOsDirection) return systemDirectionNotifier.value;
|
|
try {
|
|
final value = await _channel.invokeMethod<String>("systemDirection");
|
|
systemDirectionNotifier.value = value == "rtl"
|
|
? TextDirection.rtl
|
|
: TextDirection.ltr;
|
|
} on PlatformException {
|
|
_queryOsDirection = false;
|
|
} on MissingPluginException {
|
|
_queryOsDirection = false;
|
|
}
|
|
return systemDirectionNotifier.value;
|
|
}
|
|
|
|
/// Registers [onChanged] to be notified when the operating-system pointer-size
|
|
/// multiplier changes while the app is running (macOS). Returns a function
|
|
/// that stops the observation.
|
|
static Future<VoidCallback> observeSystemCursorScale(
|
|
ValueChanged<double> onChanged,
|
|
) async {
|
|
_scaleObserver = onChanged;
|
|
_ensureHandler();
|
|
if (!_scaleListening) {
|
|
_scaleListening = true;
|
|
try {
|
|
await _channel.invokeMethod("registerScaleObserver");
|
|
} on PlatformException {
|
|
// Ignore: observer registration not supported on this platform.
|
|
} on MissingPluginException {
|
|
// Ignore.
|
|
}
|
|
}
|
|
return () {
|
|
if (_scaleObserver == onChanged) _scaleObserver = null;
|
|
};
|
|
}
|
|
|
|
/// Registers [onChanged] to be notified when the operating-system layout
|
|
/// direction changes while the app is running (macOS). Returns a function
|
|
/// that stops the observation.
|
|
static Future<VoidCallback> observeSystemDirection(
|
|
ValueChanged<TextDirection> onChanged,
|
|
) async {
|
|
_directionObserver = onChanged;
|
|
_ensureHandler();
|
|
if (!_directionListening) {
|
|
_directionListening = true;
|
|
unawaited(systemTextDirection());
|
|
try {
|
|
await _channel.invokeMethod("registerDirectionObserver");
|
|
} on PlatformException {
|
|
// Ignore: observer registration not supported on this platform.
|
|
} on MissingPluginException {
|
|
// Ignore.
|
|
}
|
|
}
|
|
return () {
|
|
if (_directionObserver == onChanged) _directionObserver = null;
|
|
};
|
|
}
|
|
|
|
static void _ensureHandler() {
|
|
if (_handlerInstalled) return;
|
|
_handlerInstalled = true;
|
|
_channel.setMethodCallHandler((call) async {
|
|
switch (call.method) {
|
|
case "systemCursorScaleChanged":
|
|
final scale = (call.arguments as num?)?.toDouble() ?? 1.0;
|
|
_scaleObserver?.call(scale);
|
|
case "systemDirectionChanged":
|
|
final direction = call.arguments == "rtl"
|
|
? TextDirection.rtl
|
|
: TextDirection.ltr;
|
|
systemDirectionNotifier.value = direction;
|
|
_directionObserver?.call(direction);
|
|
}
|
|
return;
|
|
});
|
|
}
|
|
|
|
/// The operating-system text/layout direction (macOS), updated live.
|
|
static final ValueNotifier<TextDirection> systemDirectionNotifier =
|
|
ValueNotifier<TextDirection>(TextDirection.ltr);
|
|
|
|
/// Resolves the effective layout direction for [context].
|
|
///
|
|
/// The enclosing widget [Directionality] wins when it is explicitly
|
|
/// right-to-left. When the widget reports the default (left-to-right), the
|
|
/// operating-system direction is used as a fallback.
|
|
static TextDirection effectiveDirection(
|
|
BuildContext context, {
|
|
TextDirection? osDirection,
|
|
}) {
|
|
final widgetDirection =
|
|
Directionality.maybeOf(context) ?? TextDirection.ltr;
|
|
if (widgetDirection == TextDirection.rtl) return TextDirection.rtl;
|
|
final os = osDirection ?? systemDirectionNotifier.value;
|
|
return os == TextDirection.rtl ? TextDirection.rtl : TextDirection.ltr;
|
|
}
|
|
|
|
/// Registers every asset in [cursors] with the native side, keyed by name, so
|
|
/// hover regions can switch to them. Each asset registers both its
|
|
/// left-to-right and mirrored right-to-left variants. Static assets register
|
|
/// cursors under [name] (and `name>rtl`); animated assets register each frame
|
|
/// under `name#<i>` (and its mirrored counterpart) and keep playback metadata
|
|
/// for [setCursorAsset]. Best-effort: failures are ignored because the native
|
|
/// side may not be ready yet.
|
|
static Future<void> registerCursors(Map<String, CursorAsset> cursors) async {
|
|
await Future.wait(
|
|
cursors.entries.map((entry) async {
|
|
final asset = entry.value;
|
|
switch (asset) {
|
|
case StaticCursor():
|
|
_animated.remove(entry.key);
|
|
await _registerCursorPair(entry.key, asset.cursor);
|
|
case AnimatedCursorAsset():
|
|
final animation = asset.cursor;
|
|
_animated[entry.key] = _AnimatedRegistration(
|
|
frameDelays: animation.frameDelays,
|
|
);
|
|
for (var i = 0; i < animation.frames.length; i++) {
|
|
await _registerCursorPair("${entry.key}#$i", animation.frames[i]);
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Registers [cursor] both normally and mirrored (for RTL), disposing the
|
|
/// source image after encoding.
|
|
static Future<void> _registerCursorPair(String name, Cursor cursor) async {
|
|
await _registerCursor(name, cursor);
|
|
await _registerCursor(
|
|
"$name$_rtlSuffix",
|
|
await CursorScaler.mirror(cursor),
|
|
);
|
|
}
|
|
|
|
static Future<void> _registerCursor(String name, Cursor cursor) async {
|
|
final png = await _cursorPng(cursor.image);
|
|
try {
|
|
await _channel.invokeMethod("registerCursor", {
|
|
"name": name,
|
|
"png": png,
|
|
"hotspotX": cursor.hotspot.dx,
|
|
"hotspotY": cursor.hotspot.dy,
|
|
});
|
|
} on PlatformException {
|
|
// ignore: best-effort
|
|
} on MissingPluginException {
|
|
// ignore: best-effort
|
|
}
|
|
}
|
|
|
|
static 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 cursor [displayName] (which
|
|
/// already encodes orientation via `>rtl`), or back to the system arrow when
|
|
/// the name is unknown. Best-effort.
|
|
static Future<void> setCursor(String displayName) async {
|
|
try {
|
|
await _channel.invokeMethod("setCursor", {"name": displayName});
|
|
} on PlatformException {
|
|
// ignore: best-effort
|
|
} on MissingPluginException {
|
|
// ignore: best-effort
|
|
}
|
|
}
|
|
|
|
/// Switches the platform cursor to the registered asset [name], starting an
|
|
/// animation loop for animated assets, honoring the [rtl] orientation. Falls
|
|
/// back to the system arrow when the name is unknown. Best-effort.
|
|
static Future<void> setCursorAsset(String name, {bool rtl = false}) async {
|
|
_activeAssetName = name;
|
|
final registration = _animated[name];
|
|
if (registration == null) {
|
|
_stopAnimation();
|
|
await setCursor(rtl ? "$name$_rtlSuffix" : name);
|
|
return;
|
|
}
|
|
registration.index = 0;
|
|
_startAnimation(name, rtl);
|
|
}
|
|
|
|
/// Stops any running animation and restores the system arrow cursor.
|
|
static Future<void> resetCursor() async {
|
|
_stopAnimation();
|
|
_activeAssetName = null;
|
|
await setCursor("arrow");
|
|
}
|
|
|
|
/// Re-applies the cursor most recently shown via [setCursorAsset], using the
|
|
/// [rtl] orientation. No-op when none is active.
|
|
static Future<void> reapplyCursor({required bool rtl}) async {
|
|
final name = _activeAssetName;
|
|
if (name == null) return;
|
|
await setCursorAsset(name, rtl: rtl);
|
|
}
|
|
|
|
static void _startAnimation(String name, bool rtl) {
|
|
_animationTimer?.cancel();
|
|
final registration = _animated[name];
|
|
if (registration == null) return;
|
|
registration.index = 0;
|
|
// Show the first frame immediately; a held frame (e.g. a long first delay)
|
|
// should still be visible the moment the cursor is activated.
|
|
final suffix = rtl ? _rtlSuffix : "";
|
|
unawaited(setCursor("$name#0$suffix"));
|
|
_scheduleFrame(name, rtl);
|
|
}
|
|
|
|
static void _scheduleFrame(String name, bool rtl) {
|
|
final registration = _animated[name];
|
|
if (registration == null) return;
|
|
final delay = registration.frameDelays[registration.index];
|
|
final suffix = rtl ? _rtlSuffix : "";
|
|
_animationTimer = Timer(delay, () {
|
|
final current = _animated[name];
|
|
if (current == null) return;
|
|
current.index = (current.index + 1) % current.frameDelays.length;
|
|
unawaited(setCursor("$name#${current.index}$suffix"));
|
|
_scheduleFrame(name, rtl);
|
|
});
|
|
}
|
|
|
|
static void _stopAnimation() {
|
|
_animationTimer?.cancel();
|
|
_animationTimer = null;
|
|
_animated.forEach((_, registration) => registration.index = 0);
|
|
}
|
|
|
|
/// Wraps a [Map] of static [Cursor]s (as produced by a skin loader) into
|
|
/// [CursorAsset] values, so they can be passed to [registerCursors] or
|
|
/// [CursorRegion].
|
|
static Map<String, CursorAsset> cursorAssets(Map<String, Cursor> cursors) {
|
|
return cursors.map((name, cursor) => MapEntry(name, StaticCursor(cursor)));
|
|
}
|
|
}
|
|
|
|
/// Metadata for a registered animated cursor, used to drive the Dart-side
|
|
/// playback timer. Keyed by the direction-agnostic asset name.
|
|
class _AnimatedRegistration {
|
|
_AnimatedRegistration({required this.frameDelays});
|
|
|
|
final List<Duration> frameDelays;
|
|
int index = 0;
|
|
}
|