Move cusor handler functions into an abstract class

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-10-19 15:38:55 +02:00
parent 6d53d108be
commit e40018ab4e
3 changed files with 389 additions and 379 deletions
+281 -367
View File
@@ -9,12 +9,288 @@ import "cursor.dart";
import "cursor_asset.dart";
import "cursor_scaler.dart";
/// The platform channel used to communicate cursor operations to the native
/// side.
const _channel = MethodChannel("custom_cursor");
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.
const String _rtlSuffix = ">rtl";
/// 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.
@@ -24,365 +300,3 @@ class _AnimatedRegistration {
final List<Duration> frameDelays;
int index = 0;
}
final Map<String, _AnimatedRegistration> _animated = {};
Timer? _animationTimer;
ValueChanged<double>? _scaleObserver;
ValueChanged<TextDirection>? _directionObserver;
bool _handlerInstalled = false;
bool _scaleListening = false;
bool _directionListening = false;
/// The currently active asset name, so a live OS-direction change can re-apply
/// the visible cursor.
String? _activeAssetName;
/// The operating-system text/layout direction (macOS), updated live.
final ValueNotifier<TextDirection> systemDirectionNotifier =
ValueNotifier<TextDirection>(TextDirection.ltr);
/// Whether the platform should be queried for the OS direction. Used so tests
/// and non-macOS platforms fall back gracefully.
bool _queryOsDirection = true;
/// Reads the current operating-system pointer-size multiplier (macOS
/// accessibility setting), defaulting to `1.0` when unavailable or on
/// unsupported platforms.
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;
}
}
/// Reads the current operating-system layout direction (macOS), defaulting to
/// left-to-right when unavailable or on unsupported platforms.
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.
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.
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;
};
}
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 null;
});
}
/// 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.
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.
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.
Future<void> _registerCursorPair(String name, Cursor cursor) async {
await _registerCursor(name, cursor);
await _registerCursor("$name$_rtlSuffix", await CursorScaler.mirror(cursor));
}
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
}
}
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.
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.
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.
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.
Future<void> reapplyCursor({required bool rtl}) async {
final name = _activeAssetName;
if (name == null) return;
await setCursorAsset(name, rtl: rtl);
}
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);
}
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);
});
}
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].
Map<String, CursorAsset> cursorAssets(Map<String, Cursor> cursors) {
return cursors.map((name, cursor) => MapEntry(name, StaticCursor(cursor)));
}
/// Registers [cursors] once, then wraps [child] so hovering over it shows the
/// default cursor (`normal` key) and leaving restores the system arrow. The
/// shown cursor mirrors to the effective layout direction of [child]'s
/// subtree, and re-mirrors live when the OS direction changes.
class CursorRegion extends StatefulWidget {
const CursorRegion({required this.cursors, required this.child, super.key});
final Map<String, CursorAsset> cursors;
final Widget child;
@override
State<CursorRegion> createState() => _CursorRegionState();
}
class _CursorRegionState extends State<CursorRegion> {
// Track the current cursor locally to prevent flooding the platform channel.
String _activeCursorName = "arrow";
bool _activeRtl = false;
bool _isHovering = false;
VoidCallback? _cancelDirectionObserver;
@override
void initState() {
super.initState();
unawaited(registerCursors(widget.cursors));
unawaited(_observeDirection());
}
Future<void> _observeDirection() async {
_cancelDirectionObserver = await observeSystemDirection((_) {
if (!_isHovering || !mounted) return;
// Re-apply the active cursor with the new orientation.
_applyCursor(_activeCursorName);
});
}
@override
void didUpdateWidget(CursorRegion oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.cursors, widget.cursors)) {
unawaited(registerCursors(widget.cursors));
// If the cursors change while the mouse is already inside the widget,
// we need to re-evaluate what cursor should be showing.
if (_isHovering) {
_updateCursor();
}
}
}
@override
void dispose() {
_cancelDirectionObserver?.call();
unawaited(resetCursor());
super.dispose();
}
void _updateCursor() {
final hasCustom = widget.cursors.containsKey("normal");
_applyCursor(hasCustom ? "normal" : "arrow");
}
void _applyCursor(String name) {
final rtl = effectiveDirection(context) == TextDirection.rtl;
if (_activeCursorName == name && _activeRtl == rtl) return;
_activeCursorName = name;
_activeRtl = rtl;
unawaited(setCursorAsset(name, rtl: rtl));
}
@override
Widget build(BuildContext context) {
return MouseRegion(
hitTestBehavior: HitTestBehavior.translucent,
// onHover is intentionally omitted. We only need to know when the
// mouse enters or exits the boundary to trigger the cursor change.
onEnter: (_) {
_isHovering = true;
_updateCursor();
},
onExit: (_) {
_isHovering = false;
_activeCursorName = "arrow";
_activeRtl = false;
unawaited(resetCursor());
},
child: widget.child,
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import "dart:async";
import "package:custom_cursor/custom_cursor.dart";
import "package:flutter/widgets.dart";
/// Registers [cursors] once, then wraps [child] so hovering over it shows the
/// default cursor (`normal` key) and leaving restores the system arrow. The
/// shown cursor mirrors to the effective layout direction of [child]'s
/// subtree, and re-mirrors live when the OS direction changes.
class CursorRegion extends StatefulWidget {
const CursorRegion({required this.cursors, required this.child, super.key});
final Map<String, CursorAsset> cursors;
final Widget child;
@override
State<CursorRegion> createState() => _CursorRegionState();
}
class _CursorRegionState extends State<CursorRegion> {
// Track the current cursor locally to prevent flooding the platform channel.
String _activeCursorName = "arrow";
bool _activeRtl = false;
bool _isHovering = false;
VoidCallback? _cancelDirectionObserver;
@override
void initState() {
super.initState();
unawaited(CursorHandler.registerCursors(widget.cursors));
unawaited(_observeDirection());
}
Future<void> _observeDirection() async {
_cancelDirectionObserver = await CursorHandler.observeSystemDirection((_) {
if (!_isHovering || !mounted) return;
// Re-apply the active cursor with the new orientation.
_applyCursor(_activeCursorName);
});
}
@override
void didUpdateWidget(CursorRegion oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.cursors, widget.cursors)) {
unawaited(CursorHandler.registerCursors(widget.cursors));
// If the cursors change while the mouse is already inside the widget,
// we need to re-evaluate what cursor should be showing.
if (_isHovering) {
_updateCursor();
}
}
}
@override
void dispose() {
_cancelDirectionObserver?.call();
unawaited(CursorHandler.resetCursor());
super.dispose();
}
void _updateCursor() {
final hasCustom = widget.cursors.containsKey("normal");
_applyCursor(hasCustom ? "normal" : "arrow");
}
void _applyCursor(String name) {
final rtl = CursorHandler.effectiveDirection(context) == TextDirection.rtl;
if (_activeCursorName == name && _activeRtl == rtl) return;
_activeCursorName = name;
_activeRtl = rtl;
unawaited(CursorHandler.setCursorAsset(name, rtl: rtl));
}
@override
Widget build(BuildContext context) {
return MouseRegion(
hitTestBehavior: HitTestBehavior.translucent,
// onHover is intentionally omitted. We only need to know when the
// mouse enters or exits the boundary to trigger the cursor change.
onEnter: (_) {
_isHovering = true;
_updateCursor();
},
onExit: (_) {
_isHovering = false;
_activeCursorName = "arrow";
_activeRtl = false;
unawaited(CursorHandler.resetCursor());
},
child: widget.child,
);
}
}