blob: ced46243e8606d40c2d8248a8bd84cedf9308396 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:collection';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MaterialApp(
title: 'Actions Demo',
home: FocusDemo(),
));
}
/// A class that can hold invocation information that an [UndoableAction] can
/// use to undo/redo itself.
///
/// Instances of this class are returned from [UndoableAction]s and placed on
/// the undo stack when they are invoked.
class Memento extends Object with Diagnosticable {
const Memento({
required this.name,
required this.undo,
required this.redo,
});
/// Returns true if this Memento can be used to undo.
///
/// Subclasses could override to provide their own conditions when a command is
/// undoable.
bool get canUndo => true;
/// Returns true if this Memento can be used to redo.
///
/// Subclasses could override to provide their own conditions when a command is
/// redoable.
bool get canRedo => true;
final String name;
final VoidCallback undo;
final ValueGetter<Memento> redo;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('name', name));
}
}
/// Undoable Actions
/// An [ActionDispatcher] subclass that manages the invocation of undoable
/// actions.
class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
/// Constructs a new [UndoableActionDispatcher].
///
/// The [maxUndoLevels] argument must not be null.
UndoableActionDispatcher({
int maxUndoLevels = _defaultMaxUndoLevels,
}) : _maxUndoLevels = maxUndoLevels;
// A stack of actions that have been performed. The most recent action
// performed is at the end of the list.
final DoubleLinkedQueue<Memento> _completedActions = DoubleLinkedQueue<Memento>();
// A stack of actions that can be redone. The most recent action performed is
// at the end of the list.
final List<Memento> _undoneActions = <Memento>[];
static const int _defaultMaxUndoLevels = 1000;
/// The maximum number of undo levels allowed.
///
/// If this value is set to a value smaller than the number of completed
/// actions, then the stack of completed actions is truncated to only include
/// the last [maxUndoLevels] actions.
int get maxUndoLevels => _maxUndoLevels;
int _maxUndoLevels;
set maxUndoLevels(int value) {
_maxUndoLevels = value;
_pruneActions();
}
final Set<VoidCallback> _listeners = <VoidCallback>{};
@override
void addListener(VoidCallback listener) {
_listeners.add(listener);
}
@override
void removeListener(VoidCallback listener) {
_listeners.remove(listener);
}
/// Notifies listeners that the [ActionDispatcher] has changed state.
///
/// May only be called by subclasses.
@protected
void notifyListeners() {
for (final VoidCallback callback in _listeners) {
callback();
}
}
@override
Object? invokeAction(Action<Intent> action, Intent intent, [BuildContext? context]) {
final Object? result = super.invokeAction(action, intent, context);
print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this ');
if (action is UndoableAction) {
_completedActions.addLast(result! as Memento);
_undoneActions.clear();
_pruneActions();
notifyListeners();
}
return result;
}
// Enforces undo level limit.
void _pruneActions() {
while (_completedActions.length > _maxUndoLevels) {
_completedActions.removeFirst();
}
}
/// Returns true if there is an action on the stack that can be undone.
bool get canUndo {
if (_completedActions.isNotEmpty) {
return _completedActions.first.canUndo;
}
return false;
}
/// Returns true if an action that has been undone can be re-invoked.
bool get canRedo {
if (_undoneActions.isNotEmpty) {
return _undoneActions.first.canRedo;
}
return false;
}
/// Undoes the last action executed if possible.
///
/// Returns true if the action was successfully undone.
bool undo() {
print('Undoing. $this');
if (!canUndo) {
return false;
}
final Memento memento = _completedActions.removeLast();
memento.undo();
_undoneActions.add(memento);
notifyListeners();
return true;
}
/// Re-invokes a previously undone action, if possible.
///
/// Returns true if the action was successfully invoked.
bool redo() {
print('Redoing. $this');
if (!canRedo) {
return false;
}
final Memento memento = _undoneActions.removeLast();
final Memento replacement = memento.redo();
_completedActions.add(replacement);
_pruneActions();
notifyListeners();
return true;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('undoable items', _completedActions.length));
properties.add(IntProperty('redoable items', _undoneActions.length));
properties.add(IterableProperty<Memento>('undo stack', _completedActions));
properties.add(IterableProperty<Memento>('redo stack', _undoneActions));
}
}
class UndoIntent extends Intent {
const UndoIntent();
}
class UndoAction extends Action<UndoIntent> {
@override
bool isEnabled(UndoIntent intent) {
final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext;
if (buildContext == null) {
return false;
}
final UndoableActionDispatcher manager = Actions.of(buildContext) as UndoableActionDispatcher;
return manager.canUndo;
}
@override
void invoke(UndoIntent intent) {
final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext;
if (buildContext == null) {
return;
}
final UndoableActionDispatcher manager = Actions.of(primaryFocus?.context ?? FocusDemo.appKey.currentContext!) as UndoableActionDispatcher;
manager.undo();
}
}
class RedoIntent extends Intent {
const RedoIntent();
}
class RedoAction extends Action<RedoIntent> {
@override
bool isEnabled(RedoIntent intent) {
final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext;
if (buildContext == null) {
return false;
}
final UndoableActionDispatcher manager = Actions.of(buildContext) as UndoableActionDispatcher;
return manager.canRedo;
}
@override
RedoAction invoke(RedoIntent intent) {
final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext;
if (buildContext == null) {
return this;
}
final UndoableActionDispatcher manager = Actions.of(buildContext) as UndoableActionDispatcher;
manager.redo();
return this;
}
}
/// An action that can be undone.
abstract class UndoableAction<T extends Intent> extends Action<T> {
/// The [Intent] this action was originally invoked with.
Intent? get invocationIntent => _invocationTag;
Intent? _invocationTag;
@protected
set invocationIntent(Intent? value) => _invocationTag = value;
@override
@mustCallSuper
void invoke(T intent) {
invocationIntent = intent;
}
}
class UndoableFocusActionBase<T extends Intent> extends UndoableAction<T> {
@override
@mustCallSuper
Memento invoke(T intent) {
super.invoke(intent);
final FocusNode? previousFocus = primaryFocus;
return Memento(name: previousFocus!.debugLabel!, undo: () {
previousFocus.requestFocus();
}, redo: () {
return invoke(intent);
});
}
}
class UndoableRequestFocusAction extends UndoableFocusActionBase<RequestFocusIntent> {
@override
Memento invoke(RequestFocusIntent intent) {
final Memento memento = super.invoke(intent);
intent.focusNode.requestFocus();
return memento;
}
}
/// Actions for manipulating focus.
class UndoableNextFocusAction extends UndoableFocusActionBase<NextFocusIntent> {
@override
Memento invoke(NextFocusIntent intent) {
final Memento memento = super.invoke(intent);
primaryFocus?.nextFocus();
return memento;
}
}
class UndoablePreviousFocusAction extends UndoableFocusActionBase<PreviousFocusIntent> {
@override
Memento invoke(PreviousFocusIntent intent) {
final Memento memento = super.invoke(intent);
primaryFocus?.previousFocus();
return memento;
}
}
class UndoableDirectionalFocusAction extends UndoableFocusActionBase<DirectionalFocusIntent> {
TraversalDirection? direction;
@override
Memento invoke(DirectionalFocusIntent intent) {
final Memento memento = super.invoke(intent);
primaryFocus?.focusInDirection(intent.direction);
return memento;
}
}
/// A button class that takes focus when clicked.
class DemoButton extends StatefulWidget {
const DemoButton({super.key, required this.name});
final String name;
@override
State<DemoButton> createState() => _DemoButtonState();
}
class _DemoButtonState extends State<DemoButton> {
late final FocusNode _focusNode = FocusNode(debugLabel: widget.name);
final GlobalKey _nameKey = GlobalKey();
void _handleOnPressed() {
print('Button ${widget.name} pressed.');
setState(() {
Actions.invoke(_nameKey.currentContext!, RequestFocusIntent(_focusNode));
});
}
@override
void dispose() {
super.dispose();
_focusNode.dispose();
}
@override
Widget build(BuildContext context) {
return TextButton(
focusNode: _focusNode,
style: ButtonStyle(
foregroundColor: const MaterialStatePropertyAll<Color>(Colors.black),
overlayColor: MaterialStateProperty.resolveWith<Color>((Set<MaterialState> states) {
if (states.contains(MaterialState.focused)) {
return Colors.red;
}
if (states.contains(MaterialState.hovered)) {
return Colors.blue;
}
return Colors.transparent;
}),
),
onPressed: () => _handleOnPressed(),
child: Text(widget.name, key: _nameKey),
);
}
}
class FocusDemo extends StatefulWidget {
const FocusDemo({super.key});
static GlobalKey appKey = GlobalKey();
@override
State<FocusDemo> createState() => _FocusDemoState();
}
class _FocusDemoState extends State<FocusDemo> {
final FocusNode outlineFocus = FocusNode(debugLabel: 'Demo Focus Node');
late final UndoableActionDispatcher dispatcher = UndoableActionDispatcher();
bool canUndo = false;
bool canRedo = false;
@override
void initState() {
super.initState();
canUndo = dispatcher.canUndo;
canRedo = dispatcher.canRedo;
dispatcher.addListener(_handleUndoStateChange);
}
void _handleUndoStateChange() {
if (dispatcher.canUndo != canUndo) {
setState(() {
canUndo = dispatcher.canUndo;
});
}
if (dispatcher.canRedo != canRedo) {
setState(() {
canRedo = dispatcher.canRedo;
});
}
}
@override
void dispose() {
dispatcher.removeListener(_handleUndoStateChange);
outlineFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final TextTheme textTheme = Theme.of(context).textTheme;
return Actions(
dispatcher: dispatcher,
actions: <Type, Action<Intent>>{
RequestFocusIntent: UndoableRequestFocusAction(),
NextFocusIntent: UndoableNextFocusAction(),
PreviousFocusIntent: UndoablePreviousFocusAction(),
DirectionalFocusIntent: UndoableDirectionalFocusAction(),
UndoIntent: UndoAction(),
RedoIntent: RedoAction(),
},
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyZ, meta: Platform.isMacOS, control: !Platform.isMacOS, shift: true): const RedoIntent(),
SingleActivator(LogicalKeyboardKey.keyZ, meta: Platform.isMacOS, control: !Platform.isMacOS): const UndoIntent(),
},
child: FocusScope(
key: FocusDemo.appKey,
debugLabel: 'Scope',
autofocus: true,
child: DefaultTextStyle(
style: textTheme.headlineMedium!,
child: Scaffold(
appBar: AppBar(
title: const Text('Actions Demo'),
),
body: Center(
child: Builder(builder: (BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
DemoButton(name: 'One'),
DemoButton(name: 'Two'),
DemoButton(name: 'Three'),
],
),
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
DemoButton(name: 'Four'),
DemoButton(name: 'Five'),
DemoButton(name: 'Six'),
],
),
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
DemoButton(name: 'Seven'),
DemoButton(name: 'Eight'),
DemoButton(name: 'Nine'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: canUndo
? () {
Actions.invoke(context, const UndoIntent());
}
: null,
child: const Text('UNDO'),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: canRedo
? () {
Actions.invoke(context, const RedoIntent());
}
: null,
child: const Text('REDO'),
),
),
],
),
],
);
}),
),
),
),
),
),
),
);
}
}