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

5 6
import 'dart:collection';

7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

import 'actions.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'inherited_notifier.dart';

/// A set of [KeyboardKey]s that can be used as the keys in a [Map].
///
/// A key set contains the keys that are down simultaneously to represent a
/// shortcut.
///
/// This is a thin wrapper around a [Set], but changes the equality comparison
/// from an identity comparison to a contents comparison so that non-identical
/// sets with the same keys in them will compare as equal.
///
/// See also:
///
28
///  * [ShortcutManager], which uses [LogicalKeySet] (a [KeySet] subclass) to
29
///    define its key map.
30
class KeySet<T extends KeyboardKey> {
31 32 33 34 35 36 37 38 39 40 41 42
  /// A constructor for making a [KeySet] of up to four keys.
  ///
  /// If you need a set of more than four keys, use [KeySet.fromSet].
  ///
  /// The `key1` parameter must not be null. The same [KeyboardKey] may
  /// not be appear more than once in the set.
  KeySet(
    T key1, [
    T key2,
    T key3,
    T key4,
  ])  : assert(key1 != null),
43
        _keys = HashSet<T>()..add(key1) {
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
    int count = 1;
    if (key2 != null) {
      _keys.add(key2);
      assert(() {
        count++;
        return true;
      }());
    }
    if (key3 != null) {
      _keys.add(key3);
      assert(() {
        count++;
        return true;
      }());
    }
    if (key4 != null) {
      _keys.add(key4);
      assert(() {
        count++;
        return true;
      }());
    }
    assert(_keys.length == count, 'Two or more provided keys are identical. Each key must appear only once.');
  }

  /// Create  a [KeySet] from a set of [KeyboardKey]s.
  ///
  /// Do not mutate the `keys` set after passing it to this object.
  ///
  /// The `keys` set must not be null, contain nulls, or be empty.
  KeySet.fromSet(Set<T> keys)
      : assert(keys != null),
        assert(keys.isNotEmpty),
        assert(!keys.contains(null)),
78
        _keys = HashSet<T>.from(keys);
79 80 81

  /// Returns an unmodifiable view of the [KeyboardKey]s in this [KeySet].
  Set<T> get keys => UnmodifiableSetView<T>(_keys);
82 83 84 85
  // This needs to be a hash set to be sure that the hashCode accessor returns
  // consistent results. LinkedHashSet (the default Set implementation) depends
  // upon insertion order, and HashSet does not.
  final HashSet<T> _keys;
86 87 88 89 90 91

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
92 93
    return other is KeySet<T>
        && setEquals<T>(other._keys, _keys);
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
  }

  @override
  int get hashCode {
    return hashList(_keys);
  }
}

/// A set of [LogicalKeyboardKey]s that can be used as the keys in a map.
///
/// A key set contains the keys that are down simultaneously to represent a
/// shortcut.
///
/// This is mainly used by [ShortcutManager] to allow the definition of shortcut
/// mappings.
///
/// This is a thin wrapper around a [Set], but changes the equality comparison
/// from an identity comparison to a contents comparison so that non-identical
/// sets with the same keys in them will compare as equal.
113
class LogicalKeySet extends KeySet<LogicalKeyboardKey> with Diagnosticable {
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
  /// A constructor for making a [LogicalKeySet] of up to four keys.
  ///
  /// If you need a set of more than four keys, use [LogicalKeySet.fromSet].
  ///
  /// The `key1` parameter must not be null. The same [LogicalKeyboardKey] may
  /// not be appear more than once in the set.
  LogicalKeySet(
    LogicalKeyboardKey key1, [
    LogicalKeyboardKey key2,
    LogicalKeyboardKey key3,
    LogicalKeyboardKey key4,
  ]) : super(key1, key2, key3, key4);

  /// Create  a [LogicalKeySet] from a set of [LogicalKeyboardKey]s.
  ///
  /// Do not mutate the `keys` set after passing it to this object.
  ///
  /// The `keys` must not be null.
  LogicalKeySet.fromSet(Set<LogicalKeyboardKey> keys) : super.fromSet(keys);
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158

  static final Set<LogicalKeyboardKey> _modifiers = <LogicalKeyboardKey>{
    LogicalKeyboardKey.alt,
    LogicalKeyboardKey.control,
    LogicalKeyboardKey.meta,
    LogicalKeyboardKey.shift,
  };

  /// Returns a description of the key set that is short and readable.
  ///
  /// Intended to be used in debug mode for logging purposes.
  String debugDescribeKeys() {
    final List<LogicalKeyboardKey> sortedKeys = keys.toList()..sort(
            (LogicalKeyboardKey a, LogicalKeyboardKey b) {
          // Put the modifiers first. If it has a synonym, then it's something
          // like shiftLeft, altRight, etc.
          final bool aIsModifier = a.synonyms.isNotEmpty || _modifiers.contains(a);
          final bool bIsModifier = b.synonyms.isNotEmpty || _modifiers.contains(b);
          if (aIsModifier && !bIsModifier) {
            return -1;
          } else if (bIsModifier && !aIsModifier) {
            return 1;
          }
          return a.debugName.compareTo(b.debugName);
        }
    );
159
    return sortedKeys.map<String>((LogicalKeyboardKey key) => key.debugName.toString()).join(' + ');
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<Set<LogicalKeyboardKey>>('keys', _keys, description: debugDescribeKeys()));
  }
}

/// Diagnostics property which handles formatting a `Map<LogicalKeySet, Intent>`
/// (the same type as the [Shortcuts.shortcuts] property) so that it is human-readable.
class ShortcutMapProperty extends DiagnosticsProperty<Map<LogicalKeySet, Intent>> {
  /// Create a diagnostics property for `Map<LogicalKeySet, Intent>` objects,
  /// which are the same type as the [Shortcuts.shortcuts] property.
  ///
  /// The [showName] and [level] arguments must not be null.
  ShortcutMapProperty(
    String name,
    Map<LogicalKeySet, Intent> value, {
    bool showName = true,
    Object defaultValue = kNoDefaultValue,
    DiagnosticLevel level = DiagnosticLevel.info,
    String description,
  }) : assert(showName != null),
       assert(level != null),
       super(
         name,
         value,
         showName: showName,
         defaultValue: defaultValue,
         level: level,
         description: description,
       );

  @override
  String valueToString({ TextTreeConfiguration parentConfiguration }) {
    return '{${value.keys.map<String>((LogicalKeySet keySet) => '{${keySet.debugDescribeKeys()}}: ${value[keySet]}').join(', ')}}';
  }
198 199 200 201 202 203
}

/// A manager of keyboard shortcut bindings.
///
/// A [ShortcutManager] is obtained by calling [Shortcuts.of] on the context of
/// the widget that you want to find a manager for.
204
class ShortcutManager extends ChangeNotifier with Diagnosticable {
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229
  /// Constructs a [ShortcutManager].
  ///
  /// The [shortcuts] argument must not  be null.
  ShortcutManager({
    Map<LogicalKeySet, Intent> shortcuts = const <LogicalKeySet, Intent>{},
    this.modal = false,
  })  : assert(shortcuts != null),
        _shortcuts = shortcuts;

  /// True if the [ShortcutManager] should not pass on keys that it doesn't
  /// handle to any key-handling widgets that are ancestors to this one.
  ///
  /// Setting [modal] to true is the equivalent of always handling any key given
  /// to it, even if that key doesn't appear in the [shortcuts] map. Keys that
  /// don't appear in the map will be dropped.
  final bool modal;

  /// Returns the shortcut map.
  ///
  /// When the map is changed, listeners to this manager will be notified.
  ///
  /// The returned [LogicalKeyMap] should not be modified.
  Map<LogicalKeySet, Intent> get shortcuts => _shortcuts;
  Map<LogicalKeySet, Intent> _shortcuts;
  set shortcuts(Map<LogicalKeySet, Intent> value) {
230
    if (!mapEquals<LogicalKeySet, Intent>(_shortcuts, value)) {
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256
      _shortcuts = value;
      notifyListeners();
    }
  }

  /// Handles a key pressed `event` in the given `context`.
  ///
  /// The optional `keysPressed` argument provides an override to keys that the
  /// [RawKeyboard] reports. If not specified, uses [RawKeyboard.keysPressed]
  /// instead.
  @protected
  bool handleKeypress(
    BuildContext context,
    RawKeyEvent event, {
    LogicalKeySet keysPressed,
  }) {
    if (event is! RawKeyDownEvent) {
      return false;
    }
    assert(context != null);
    final LogicalKeySet keySet = keysPressed ?? LogicalKeySet.fromSet(RawKeyboard.instance.keysPressed);
    Intent matchedIntent = _shortcuts[keySet];
    if (matchedIntent == null) {
      // If there's not a more specific match, We also look for any keys that
      // have synonyms in the map.  This is for things like left and right shift
      // keys mapping to just the "shift" pseudo-key.
257
      final Set<LogicalKeyboardKey> pseudoKeys = <LogicalKeyboardKey>{};
258
      for (final LogicalKeyboardKey setKey in keySet.keys) {
259 260 261 262 263 264 265 266 267 268 269
        final Set<LogicalKeyboardKey> synonyms = setKey.synonyms;
        if (synonyms.isNotEmpty) {
          // There currently aren't any synonyms that match more than one key.
          pseudoKeys.add(synonyms.first);
        } else {
          pseudoKeys.add(setKey);
        }
      }
      matchedIntent = _shortcuts[LogicalKeySet.fromSet(pseudoKeys)];
    }
    if (matchedIntent != null) {
270
      final BuildContext primaryContext = primaryFocus?.context;
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292
      if (primaryContext == null) {
        return false;
      }
      return Actions.invoke(primaryContext, matchedIntent, nullOk: true);
    }
    return false;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<Map<LogicalKeySet, Intent>>('shortcuts', _shortcuts));
    properties.add(FlagProperty('modal', value: modal, ifTrue: 'modal', defaultValue: false));
  }
}

/// A widget that establishes an [ShortcutManager] to be used by its descendants
/// when invoking an [Action] via a keyboard key combination that maps to an
/// [Intent].
///
/// See also:
///
293 294 295
///  * [Intent], a class for containing a description of a user action to be
///    invoked.
///  * [Action], a class for defining an invocation of a user action.
296 297 298 299 300 301 302 303 304
class Shortcuts extends StatefulWidget {
  /// Creates a ActionManager object.
  ///
  /// The [child] argument must not be null.
  const Shortcuts({
    Key key,
    this.manager,
    this.shortcuts,
    this.child,
305
    this.debugLabel,
306 307 308 309 310 311 312 313 314 315 316
  }) : super(key: key);

  /// The [ShortcutManager] that will manage the mapping between key
  /// combinations and [Action]s.
  ///
  /// If not specified, uses a default-constructed [ShortcutManager].
  ///
  /// This manager will be given new [shortcuts] to manage whenever the
  /// [shortcuts] change materially.
  final ShortcutManager manager;

317 318
  /// {@template flutter.widgets.shortcuts.shortcuts}
  /// The map of shortcuts that the [ShortcutManager] will be given to manage.
319 320 321 322
  ///
  /// For performance reasons, it is recommended that a pre-built map is passed
  /// in here (e.g. a final variable from your widget class) instead of defining
  /// it inline in the build function.
323
  /// {@endtemplate}
324 325 326 327 328 329 330
  final Map<LogicalKeySet, Intent> shortcuts;

  /// The child widget for this [Shortcuts] widget.
  ///
  /// {@macro flutter.widgets.child}
  final Widget child;

331 332 333 334 335 336 337 338 339
  /// The debug label that is printed for this node when logged.
  ///
  /// If this label is set, then it will be displayed instead of the shortcut
  /// map when logged.
  ///
  /// This allows simplifying the diagnostic output to avoid cluttering it
  /// unnecessarily with the default shortcut map.
  final String debugLabel;

340 341 342 343 344 345
  /// Returns the [ActionDispatcher] that most tightly encloses the given
  /// [BuildContext].
  ///
  /// The [context] argument must not be null.
  static ShortcutManager of(BuildContext context, {bool nullOk = false}) {
    assert(context != null);
346
    final _ShortcutsMarker inherited = context.dependOnInheritedWidgetOfExactType<_ShortcutsMarker>();
347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370
    assert(() {
      if (nullOk) {
        return true;
      }
      if (inherited == null) {
        throw FlutterError('Unable to find a $Shortcuts widget in the context.\n'
            '$Shortcuts.of() was called with a context that does not contain a '
            '$Shortcuts widget.\n'
            'No $Shortcuts ancestor could be found starting from the context that was '
            'passed to $Shortcuts.of().\n'
            'The context used was:\n'
            '  $context');
      }
      return true;
    }());
    return inherited?.notifier;
  }

  @override
  _ShortcutsState createState() => _ShortcutsState();

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
371 372
    properties.add(DiagnosticsProperty<ShortcutManager>('manager', manager, defaultValue: null));
    properties.add(ShortcutMapProperty('shortcuts', shortcuts, description: debugLabel?.isNotEmpty ?? false ? debugLabel : null));
373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397
  }
}

class _ShortcutsState extends State<Shortcuts> {
  ShortcutManager _internalManager;
  ShortcutManager get manager => widget.manager ?? _internalManager;

  @override
  void dispose() {
    _internalManager?.dispose();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    if (widget.manager == null) {
      _internalManager = ShortcutManager();
    }
    manager.shortcuts = widget.shortcuts;
  }

  @override
  void didUpdateWidget(Shortcuts oldWidget) {
    super.didUpdateWidget(oldWidget);
398
    if (widget.manager != oldWidget.manager) {
399 400 401 402 403 404 405
      if (widget.manager != null) {
        _internalManager?.dispose();
        _internalManager = null;
      } else {
        _internalManager ??= ShortcutManager();
      }
    }
406
    manager.shortcuts = widget.shortcuts;
407 408 409 410 411 412 413 414 415 416 417 418
  }

  bool _handleOnKey(FocusNode node, RawKeyEvent event) {
    if (node.context == null) {
      return false;
    }
    return manager.handleKeypress(node.context, event) || manager.modal;
  }

  @override
  Widget build(BuildContext context) {
    return Focus(
419
      debugLabel: '$Shortcuts',
420
      canRequestFocus: false,
421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437
      onKey: _handleOnKey,
      child: _ShortcutsMarker(
        manager: manager,
        child: widget.child,
      ),
    );
  }
}

class _ShortcutsMarker extends InheritedNotifier<ShortcutManager> {
  const _ShortcutsMarker({
    @required ShortcutManager manager,
    @required Widget child,
  })  : assert(manager != null),
        assert(child != null),
        super(notifier: manager, child: child);
}