Files
2026-10-19 15:38:55 +02:00

560 lines
16 KiB
Dart

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<CursorGridScreen> createState() => _CursorGridScreenState();
}
class _CursorGridScreenState extends State<CursorGridScreen> {
static const _defaultAssets = <String>["normal", "move", "resize"];
Map<String, CursorAsset> _cursors = {};
final Map<String, Uint8List> _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<void> _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<void> _loadDefaults() async {
try {
final loaded = <String, CursorAsset>{};
_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<void> _onDrop(DropDoneDetails detail) async {
final loaded = <String, CursorAsset>{};
final raw = <String, Uint8List>{};
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<void> _redecode() async {
if (_rawFiles.isEmpty) {
unawaited(CursorHandler.registerCursors(_cursors));
return;
}
final loaded = <String, CursorAsset>{};
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<void> _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<int?> onPreset;
final ValueChanged<bool> onToggleOsScale;
final ValueChanged<bool> 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<DropEventDetails> onDragEntered;
final ValueChanged<DropEventDetails> onDragExited;
final ValueChanged<DropDoneDetails> 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<CursorCard> createState() => _CursorCardState();
}
class _CursorCardState extends State<CursorCard> {
bool _hovering = false;
VoidCallback? _cancelDirectionObserver;
@override
void initState() {
super.initState();
unawaited(_observeDirection());
}
Future<void> _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,
),
),
);
}
}