import "dart:async"; import "dart:io"; import "package:custom_cursor/custom_cursor.dart"; import "package:desktop_drop/desktop_drop.dart"; import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart" show rootBundle; void main() { runApp(const CursorExampleApp()); } /// The example app: loads the bundled `.cur` files, decodes them, and shows a /// grid of cards previewing each pointer. Hovering a card switches the /// platform cursor to that pointer. Dropping any supported file (`.cur`, /// `.ani`, Linux `.cursor`, PNG, GIF, SVG, ...) replaces the list; a reset /// button restores the defaults. A size selector and an OS-pointer-size toggle /// control how cursors are scaled. class CursorExampleApp extends StatelessWidget { const CursorExampleApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: "custom_cursor example", theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), ), home: const CursorGridScreen(), ); } } class CursorGridScreen extends StatefulWidget { const CursorGridScreen({super.key}); @override State createState() => _CursorGridScreenState(); } class _CursorGridScreenState extends State { static const _defaultAssets = ["normal", "move", "resize"]; Map _cursors = {}; final Map _rawFiles = {}; bool _loading = true; String? _error; bool _dragging = false; double? _targetSize = 32; bool _respectOsScale = true; bool _rtl = false; double _systemScale = 1.0; DecodeOptions get _options { return DecodeOptions( targetSize: _targetSize, maxSize: 128, systemScale: _respectOsScale ? _systemScale : 1.0, ); } @override void initState() { super.initState(); unawaited(_init()); } Future _init() async { _systemScale = await CursorHandler.systemCursorScale(); unawaited( CursorHandler.observeSystemCursorScale((scale) { if (!mounted) return; setState(() => _systemScale = scale); unawaited(_redecode()); }), ); unawaited( CursorHandler.observeSystemDirection((_) { if (!mounted) return; setState(() {}); }), ); await _loadDefaults(); } /// Loads and registers the bundled `.cur` assets, populating [_cursors]. Future _loadDefaults() async { try { final loaded = {}; _rawFiles.clear(); for (final stem in _defaultAssets) { final data = await rootBundle.load("assets/cursors/$stem.cur"); final bytes = data.buffer.asUint8List( data.offsetInBytes, data.lengthInBytes, ); loaded[stem] = await decodeCursorAsset(bytes); _rawFiles[stem] = bytes; } if (!mounted) return; setState(() { _cursors = loaded; _loading = false; }); unawaited(CursorHandler.registerCursors(_cursors)); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _loading = false; }); } } bool get _deviated { final defaultKeys = _defaultAssets.toSet(); return _cursors.keys.toSet() != defaultKeys; } /// Sniffs and decodes every dropped file (no extension filter), replacing the /// whole cursor set. Future _onDrop(DropDoneDetails detail) async { final loaded = {}; final raw = {}; for (final file in detail.files) { try { final bytes = await File(file.path).readAsBytes(); final asset = await decodeCursorAsset(bytes, options: _options); loaded[file.name.split(".").first] = asset; raw[file.name.split(".").first] = bytes; } on FormatException { // Skip unsupported or malformed files. } } if (loaded.isEmpty) return; if (!mounted) return; setState(() { _cursors = loaded; _rawFiles ..clear() ..addAll(raw); _dragging = false; }); unawaited(CursorHandler.registerCursors(_cursors)); } /// Re-decodes any stored raw files with the current options, then re-registers. Future _redecode() async { if (_rawFiles.isEmpty) { unawaited(CursorHandler.registerCursors(_cursors)); return; } final loaded = {}; for (final entry in _rawFiles.entries) { try { loaded[entry.key] = await decodeCursorAsset( entry.value, options: _options, ); } on FormatException { // Skip files that no longer decode under the new options. } } if (!mounted) return; setState(() => _cursors = loaded); unawaited(CursorHandler.registerCursors(_cursors)); } Future _reset() async { setState(() { _cursors = {}; _loading = true; _targetSize = 32; }); await _loadDefaults(); } void _setPreset(int? size) { _targetSize = size?.toDouble(); unawaited(_redecode()); } void _toggleOsScale(bool value) { _respectOsScale = value; unawaited(_redecode()); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("custom_cursor example"), actions: [ if (_deviated) IconButton( tooltip: "Reset to defaults", icon: const Icon(Icons.refresh), onPressed: () => unawaited(_reset()), ), ], ), body: _loading ? const Center(child: CircularProgressIndicator()) : _error != null ? Center(child: Text("Failed to load cursors: $_error")) : Column( children: [ _ScaleControls( preset: _targetSize, respectOsScale: _respectOsScale, osScale: _systemScale, rtl: _rtl, onPreset: (size) => setState(() => _setPreset(size)), onToggleOsScale: (value) => setState(() => _toggleOsScale(value)), onToggleRtl: (value) => setState(() => _rtl = value), ), _DropRegion( dragging: _dragging, onDragEntered: (_) => setState(() => _dragging = true), onDragExited: (_) => setState(() => _dragging = false), onDragDone: _onDrop, ), Expanded( child: Directionality( textDirection: _rtl ? TextDirection.rtl : TextDirection.ltr, child: GridView.count( padding: const EdgeInsets.all(24), crossAxisCount: 3, mainAxisSpacing: 16, crossAxisSpacing: 16, children: [ for (final entry in _cursors.entries) CursorCard(name: entry.key, asset: entry.value), ], ), ), ), ], ), ); } } /// A control bar exposing the cursor-size presets, a custom size, and the /// OS-pointer-size toggle. class _ScaleControls extends StatelessWidget { const _ScaleControls({ required this.preset, required this.respectOsScale, required this.osScale, required this.rtl, required this.onPreset, required this.onToggleOsScale, required this.onToggleRtl, }); final double? preset; final bool respectOsScale; final double osScale; final bool rtl; final ValueChanged onPreset; final ValueChanged onToggleOsScale; final ValueChanged onToggleRtl; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), child: Wrap( spacing: 12, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text("Size:", style: Theme.of(context).textTheme.titleSmall), Slider.adaptive( min: 16, max: 128, value: preset ?? 32, divisions: 8, showValueIndicator: ShowValueIndicator.alwaysVisible, onChanged: (value) { onPreset(value.toInt()); }, ), Row( spacing: 8, children: [ Directionality( textDirection: TextDirection.ltr, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text("Dir:", style: Theme.of(context).textTheme.titleSmall), const SizedBox(width: 8), ChoiceChip( label: const Text("LTR"), selected: !rtl, onSelected: (_) => onToggleRtl(false), ), const SizedBox(width: 4), ChoiceChip( label: const Text("RTL"), selected: rtl, onSelected: (_) => onToggleRtl(true), ), ], ), ), Expanded( child: SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text( "Respect OS pointer size (${osScale.toStringAsFixed(2)}x)", ), value: respectOsScale, onChanged: onToggleOsScale, ), ), ], ), ], ), ); } } /// A tappable drop region that accepts any supported cursor/image file. class _DropRegion extends StatelessWidget { const _DropRegion({ required this.dragging, required this.onDragEntered, required this.onDragExited, required this.onDragDone, }); final bool dragging; final ValueChanged onDragEntered; final ValueChanged onDragExited; final ValueChanged onDragDone; bool get useDropTarget => kIsWeb || Platform.isWindows || Platform.isLinux || Platform.isMacOS; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), child: DropTarget( enable: useDropTarget, onDragEntered: onDragEntered, onDragExited: onDragExited, onDragDone: onDragDone, child: AnimatedContainer( duration: const Duration(milliseconds: 150), height: 120, alignment: Alignment.center, decoration: BoxDecoration( color: dragging ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( color: dragging ? theme.colorScheme.primary : theme.colorScheme.outlineVariant, width: dragging ? 2 : 1, ), ), child: Text( useDropTarget ? dragging ? "Release to load cursors" : "Drop cursor/image files here (cur, ani, PNG, GIF, SVG...)" : "Tap to load cursor/image files (cur, ani, PNG, GIF, SVG...)", style: theme.textTheme.titleMedium, textAlign: TextAlign.center, ), ), ), ); } } /// A card that previews [asset] beneath [name], switching the platform cursor /// to it while being hovered, with an optional remove affordance. class CursorCard extends StatefulWidget { const CursorCard({required this.name, required this.asset, super.key}); final String name; final CursorAsset asset; @override State createState() => _CursorCardState(); } class _CursorCardState extends State { bool _hovering = false; VoidCallback? _cancelDirectionObserver; @override void initState() { super.initState(); unawaited(_observeDirection()); } Future _observeDirection() async { _cancelDirectionObserver = await CursorHandler.observeSystemDirection((_) { if (!_hovering || !mounted) return; // Re-apply the active cursor with the flipped orientation. _applyCursor(); }); } void _applyCursor() { final rtl = CursorHandler.effectiveDirection(context) == TextDirection.rtl; unawaited(CursorHandler.setCursorAsset(widget.name, rtl: rtl)); } @override void dispose() { _cancelDirectionObserver?.call(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return MouseRegion( onEnter: (_) { setState(() => _hovering = true); _applyCursor(); }, onExit: (_) { setState(() => _hovering = false); unawaited(CursorHandler.resetCursor()); }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: _hovering ? theme.colorScheme.primaryContainer : theme.colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( color: _hovering ? theme.colorScheme.primary : theme.colorScheme.outlineVariant, width: _hovering ? 2 : 1, ), ), child: Column( spacing: 12, mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible(child: AssetPreview(asset: widget.asset)), Text(widget.name, style: theme.textTheme.titleMedium), ], ), ), ); } } /// Renders a [CursorAsset], animating any animated asset's frames. class AssetPreview extends StatelessWidget { const AssetPreview({required this.asset, super.key}); final CursorAsset asset; @override Widget build(BuildContext context) { return switch (asset) { StaticCursor(:final cursor) => _CursorPreview(cursor: cursor), AnimatedCursorAsset(:final cursor) => _AnimatedCursorPreview( animated: cursor, ), }; } } /// Cycles through the frames of [animated], honoring each frame's duration. class _AnimatedCursorPreview extends StatefulWidget { const _AnimatedCursorPreview({required this.animated}); final AnimatedCursor animated; @override State<_AnimatedCursorPreview> createState() => _AnimatedCursorPreviewState(); } class _AnimatedCursorPreviewState extends State<_AnimatedCursorPreview> { late int _index; Timer? _timer; @override void initState() { super.initState(); _index = 0; if (widget.animated.frames.length > 1) { _schedule(); } } void _schedule() { final delays = widget.animated.frameDelays; final duration = _index < delays.length ? delays[_index] : const Duration(milliseconds: 16); _timer = Timer(duration, () { if (!mounted) return; setState(() { _index = (_index + 1) % widget.animated.frames.length; }); _schedule(); }); } @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.animated.frames.isEmpty) return const SizedBox.shrink(); return _CursorPreview(cursor: widget.animated.frames[_index]); } } /// Draws [cursor] centered in the card without clipping it, mirroring /// horizontally when the effective layout direction is right-to-left. class _CursorPreview extends StatelessWidget { const _CursorPreview({required this.cursor}); final Cursor cursor; @override Widget build(BuildContext context) { final rtl = CursorHandler.effectiveDirection(context) == TextDirection.rtl; final width = cursor.image.width.toDouble(); final height = cursor.image.height.toDouble(); return Center( child: Transform.scale( scaleX: rtl ? -1 : 1, child: RawImage( image: cursor.image, width: width, height: height, ), ), ); } }