// 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)); properties.add(FlagProperty('undo', value: undo != null, ifTrue: 'undo')); properties.add(FlagProperty('redo', value: redo != null, ifTrue: 'redo')); } } /// 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, }) : assert(maxUndoLevels != null), _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({Key? key, required this.name}) : super(key: key); 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: MaterialStateProperty.all<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({Key? key}) : super(key: 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.headline4!, child: Scaffold( appBar: AppBar( title: const Text('Actions Demo'), ), body: Center( child: Builder(builder: (BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Row( mainAxisAlignment: MainAxisAlignment.center, children: const <Widget>[ DemoButton(name: 'One'), DemoButton(name: 'Two'), DemoButton(name: 'Three'), ], ), Row( mainAxisAlignment: MainAxisAlignment.center, children: const <Widget>[ DemoButton(name: 'Four'), DemoButton(name: 'Five'), DemoButton(name: 'Six'), ], ), Row( mainAxisAlignment: MainAxisAlignment.center, children: const <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'), ), ), ], ), ], ); }), ), ), ), ), ), ), ); } }