e40018ab4e
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
560 lines
16 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|