actions.dart 14.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
Ian Hickson's avatar
Ian Hickson committed
4

5 6 7
import 'dart:collection';
import 'dart:io';

8 9 10 11
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

12
void main() {
13 14 15 16 17 18
  runApp(const MaterialApp(
    title: 'Actions Demo',
    home: FocusDemo(),
  ));
}

19 20 21 22 23
/// 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.
24
class Memento extends Object with Diagnosticable {
25
  const Memento({
26 27 28
    required this.name,
    required this.undo,
    required this.redo,
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
  });

  /// 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));
  }
}

54 55 56 57 58 59 60
/// Undoable Actions

/// An [ActionDispatcher] subclass that manages the invocation of undoable
/// actions.
class UndoableActionDispatcher extends ActionDispatcher implements Listenable {
  // A stack of actions that have been performed. The most recent action
  // performed is at the end of the list.
61
  final DoubleLinkedQueue<Memento> _completedActions = DoubleLinkedQueue<Memento>();
62 63
  // A stack of actions that can be redone. The most recent action performed is
  // at the end of the list.
64
  final List<Memento> _undoneActions = <Memento>[];
65 66 67 68 69 70

  /// 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.
71
  int get maxUndoLevels => 1000;
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89

  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() {
90
    for (final VoidCallback callback in _listeners) {
91 92 93 94 95
      callback();
    }
  }

  @override
96 97
  Object? invokeAction(Action<Intent> action, Intent intent, [BuildContext? context]) {
    final Object? result = super.invokeAction(action, intent, context);
98 99
    print('Invoking ${action is UndoableAction ? 'undoable ' : ''}$intent as $action: $this ');
    if (action is UndoableAction) {
100
      _completedActions.addLast(result! as Memento);
101 102 103 104 105 106 107 108 109
      _undoneActions.clear();
      _pruneActions();
      notifyListeners();
    }
    return result;
  }

  // Enforces undo level limit.
  void _pruneActions() {
110
    while (_completedActions.length > maxUndoLevels) {
111
      _completedActions.removeFirst();
112 113 114 115 116 117
    }
  }

  /// Returns true if there is an action on the stack that can be undone.
  bool get canUndo {
    if (_completedActions.isNotEmpty) {
118
      return _completedActions.first.canUndo;
119 120 121 122 123 124 125
    }
    return false;
  }

  /// Returns true if an action that has been undone can be re-invoked.
  bool get canRedo {
    if (_undoneActions.isNotEmpty) {
126
      return _undoneActions.first.canRedo;
127 128 129 130 131 132 133 134 135 136 137 138
    }
    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;
    }
139 140 141
    final Memento memento = _completedActions.removeLast();
    memento.undo();
    _undoneActions.add(memento);
142 143 144 145 146 147 148 149 150 151 152 153
    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;
    }
154 155 156
    final Memento memento = _undoneActions.removeLast();
    final Memento replacement = memento.redo();
    _completedActions.add(replacement);
157 158 159 160 161 162 163 164 165 166
    _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));
167 168
    properties.add(IterableProperty<Memento>('undo stack', _completedActions));
    properties.add(IterableProperty<Memento>('redo stack', _undoneActions));
169 170 171 172
  }
}

class UndoIntent extends Intent {
173 174
  const UndoIntent();
}
175

176
class UndoAction extends Action<UndoIntent> {
177
  @override
178
  bool isEnabled(UndoIntent intent) {
179 180 181 182 183
    final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext;
    if (buildContext == null) {
      return false;
    }
    final UndoableActionDispatcher manager = Actions.of(buildContext) as UndoableActionDispatcher;
184 185
    return manager.canUndo;
  }
186 187 188

  @override
  void invoke(UndoIntent intent) {
189 190 191 192 193 194
    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();
195
  }
196 197 198
}

class RedoIntent extends Intent {
199 200
  const RedoIntent();
}
201

202
class RedoAction extends Action<RedoIntent> {
203
  @override
204
  bool isEnabled(RedoIntent intent) {
205 206 207 208 209
    final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext;
    if (buildContext == null) {
      return false;
    }
    final UndoableActionDispatcher manager = Actions.of(buildContext) as UndoableActionDispatcher;
210 211 212
    return manager.canRedo;
  }

213 214
  @override
  RedoAction invoke(RedoIntent intent) {
215 216 217 218 219 220
    final BuildContext? buildContext = primaryFocus?.context ?? FocusDemo.appKey.currentContext;
    if (buildContext == null) {
      return this;
    }
    final UndoableActionDispatcher manager = Actions.of(buildContext) as UndoableActionDispatcher;
    manager.redo();
221 222 223
    return this;
  }
}
224 225

/// An action that can be undone.
226
abstract class UndoableAction<T extends Intent> extends Action<T> { }
227

228
class UndoableFocusActionBase<T extends Intent> extends UndoableAction<T> {
229
  @override
230 231
  @mustCallSuper
  Memento invoke(T intent) {
232 233
    final FocusNode? previousFocus = primaryFocus;
    return Memento(name: previousFocus!.debugLabel!, undo: () {
234 235 236 237
      previousFocus.requestFocus();
    }, redo: () {
      return invoke(intent);
    });
238 239 240
  }
}

241
class UndoableRequestFocusAction extends UndoableFocusActionBase<RequestFocusIntent> {
242
  @override
243 244 245 246
  Memento invoke(RequestFocusIntent intent) {
    final Memento memento = super.invoke(intent);
    intent.focusNode.requestFocus();
    return memento;
247 248 249 250
  }
}

/// Actions for manipulating focus.
251
class UndoableNextFocusAction extends UndoableFocusActionBase<NextFocusIntent> {
252
  @override
253 254
  Memento invoke(NextFocusIntent intent) {
    final Memento memento = super.invoke(intent);
255
    primaryFocus?.nextFocus();
256
    return memento;
257 258 259
  }
}

260
class UndoablePreviousFocusAction extends UndoableFocusActionBase<PreviousFocusIntent> {
261
  @override
262 263
  Memento invoke(PreviousFocusIntent intent) {
    final Memento memento = super.invoke(intent);
264
    primaryFocus?.previousFocus();
265
    return memento;
266 267 268
  }
}

269
class UndoableDirectionalFocusAction extends UndoableFocusActionBase<DirectionalFocusIntent> {
270
  @override
271 272
  Memento invoke(DirectionalFocusIntent intent) {
    final Memento memento = super.invoke(intent);
273
    primaryFocus?.focusInDirection(intent.direction);
274
    return memento;
275 276 277 278 279
  }
}

/// A button class that takes focus when clicked.
class DemoButton extends StatefulWidget {
280
  const DemoButton({super.key, required this.name});
281 282 283 284

  final String name;

  @override
285
  State<DemoButton> createState() => _DemoButtonState();
286 287 288
}

class _DemoButtonState extends State<DemoButton> {
289
  late final FocusNode _focusNode = FocusNode(debugLabel: widget.name);
290
  final GlobalKey _nameKey = GlobalKey();
291 292 293 294

  void _handleOnPressed() {
    print('Button ${widget.name} pressed.');
    setState(() {
295
      Actions.invoke(_nameKey.currentContext!, RequestFocusIntent(_focusNode));
296 297 298 299 300 301 302 303 304 305 306
    });
  }

  @override
  void dispose() {
    super.dispose();
    _focusNode.dispose();
  }

  @override
  Widget build(BuildContext context) {
307
    return TextButton(
308
      focusNode: _focusNode,
309
      style: ButtonStyle(
310
        foregroundColor: const MaterialStatePropertyAll<Color>(Colors.black),
311
        overlayColor: MaterialStateProperty.resolveWith<Color>((Set<MaterialState> states) {
312
          if (states.contains(MaterialState.focused)) {
313
            return Colors.red;
314 315
          }
          if (states.contains(MaterialState.hovered)) {
316
            return Colors.blue;
317
          }
318
          return Colors.transparent;
319 320
        }),
      ),
321
      onPressed: () => _handleOnPressed(),
322
      child: Text(widget.name, key: _nameKey),
323 324 325 326 327
    );
  }
}

class FocusDemo extends StatefulWidget {
328
  const FocusDemo({super.key});
329

330 331
  static GlobalKey appKey = GlobalKey();

332
  @override
333
  State<FocusDemo> createState() => _FocusDemoState();
334 335 336
}

class _FocusDemoState extends State<FocusDemo> {
337 338 339 340
  final FocusNode outlineFocus = FocusNode(debugLabel: 'Demo Focus Node');
  late final UndoableActionDispatcher dispatcher = UndoableActionDispatcher();
  bool canUndo = false;
  bool canRedo = false;
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372

  @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;
373 374
    return Actions(
      dispatcher: dispatcher,
375 376 377 378 379 380 381
      actions: <Type, Action<Intent>>{
        RequestFocusIntent: UndoableRequestFocusAction(),
        NextFocusIntent: UndoableNextFocusAction(),
        PreviousFocusIntent: UndoablePreviousFocusAction(),
        DirectionalFocusIntent: UndoableDirectionalFocusAction(),
        UndoIntent: UndoAction(),
        RedoIntent: RedoAction(),
382
      },
383
      child: FocusTraversalGroup(
384 385
        policy: ReadingOrderTraversalPolicy(),
        child: Shortcuts(
386 387 388
          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(),
389 390
          },
          child: FocusScope(
391
            key: FocusDemo.appKey,
392 393 394
            debugLabel: 'Scope',
            autofocus: true,
            child: DefaultTextStyle(
395
              style: textTheme.headlineMedium!,
396 397 398 399 400 401 402 403 404
              child: Scaffold(
                appBar: AppBar(
                  title: const Text('Actions Demo'),
                ),
                body: Center(
                  child: Builder(builder: (BuildContext context) {
                    return Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
405
                        const Row(
406
                          mainAxisAlignment: MainAxisAlignment.center,
407
                          children: <Widget>[
408 409 410 411 412
                            DemoButton(name: 'One'),
                            DemoButton(name: 'Two'),
                            DemoButton(name: 'Three'),
                          ],
                        ),
413
                        const Row(
414
                          mainAxisAlignment: MainAxisAlignment.center,
415
                          children: <Widget>[
416 417 418 419 420
                            DemoButton(name: 'Four'),
                            DemoButton(name: 'Five'),
                            DemoButton(name: 'Six'),
                          ],
                        ),
421
                        const Row(
422
                          mainAxisAlignment: MainAxisAlignment.center,
423
                          children: <Widget>[
424 425 426 427 428 429 430 431 432 433
                            DemoButton(name: 'Seven'),
                            DemoButton(name: 'Eight'),
                            DemoButton(name: 'Nine'),
                          ],
                        ),
                        Row(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: <Widget>[
                            Padding(
                              padding: const EdgeInsets.all(8.0),
434
                              child: ElevatedButton(
435 436
                                onPressed: canUndo
                                    ? () {
437
                                        Actions.invoke(context, const UndoIntent());
438 439
                                      }
                                    : null,
440
                                child: const Text('UNDO'),
441
                              ),
442 443 444
                            ),
                            Padding(
                              padding: const EdgeInsets.all(8.0),
445
                              child: ElevatedButton(
446 447
                                onPressed: canRedo
                                    ? () {
448
                                        Actions.invoke(context, const RedoIntent());
449 450
                                      }
                                    : null,
451
                                child: const Text('REDO'),
452
                              ),
453 454 455 456 457 458
                            ),
                          ],
                        ),
                      ],
                    );
                  }),
459 460 461 462 463 464 465 466 467
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}