diff --git a/lib/const/shortcut_info.dart b/lib/const/shortcut_info.dart index 2dd6baaa..3fd1c313 100644 --- a/lib/const/shortcut_info.dart +++ b/lib/const/shortcut_info.dart @@ -17,6 +17,7 @@ enum IcarusShortcutAction { switchSide, switchSideThisPage, openDeleteMenu, + selectAll, rotateClockwise, rotateCounterclockwise, saveStrategy, @@ -304,6 +305,18 @@ class ShortcutInfo { intent: ContextualDeleteIntent(), searchAliases: ['delete'], ), + // Taken by CanvasSelectionLayer while the canvas is on screen, beside the + // fixed Delete, Backspace and Escape. + IcarusShortcutDefinition( + action: IcarusShortcutAction.selectAll, + title: 'Select All', + defaultBinding: IcarusKeyBinding( + trigger: LogicalKeyboardKey.keyA, + primary: true, + ), + intent: SelectAllIntent(), + searchAliases: ['select'], + ), IcarusShortcutDefinition( action: IcarusShortcutAction.rotateClockwise, title: 'Rotate Clockwise', @@ -525,6 +538,10 @@ class ContextualDeleteIntent extends Intent { const ContextualDeleteIntent(); } +class SelectAllIntent extends Intent { + const SelectAllIntent(); +} + class RotateHoveredIntent extends Intent { const RotateHoveredIntent({required this.clockwise}); diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index ecb23eaf..1ab7272d 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1104,13 +1104,14 @@ class AppPreferencesAdapter extends TypeAdapter { fields[21] == null ? false : fields[21] as bool, transparentVideoBackground: fields[22] == null ? false : fields[22] as bool, + canvasSelectionHintSeen: fields[23] == null ? false : fields[23] as bool, ); } @override void write(BinaryWriter writer, AppPreferences obj) { writer - ..writeByte(22) + ..writeByte(23) ..writeByte(0) ..write(obj.defaultThemeProfileIdForNewStrategies) ..writeByte(1) @@ -1154,7 +1155,9 @@ class AppPreferencesAdapter extends TypeAdapter { ..writeByte(21) ..write(obj.transparentScreenshotBackground) ..writeByte(22) - ..write(obj.transparentVideoBackground); + ..write(obj.transparentVideoBackground) + ..writeByte(23) + ..write(obj.canvasSelectionHintSeen); } @override diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 71b21b08..623a254b 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -359,7 +359,7 @@ types: index: 4 AppPreferences: typeId: 28 - nextIndex: 23 + nextIndex: 24 fields: defaultThemeProfileIdForNewStrategies: index: 0 @@ -405,6 +405,8 @@ types: index: 21 transparentVideoBackground: index: 22 + canvasSelectionHintSeen: + index: 23 PlacedViewConeAgent: typeId: 29 nextIndex: 11 diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index 23e67f97..19ed0745 100644 --- a/lib/interactive_map.dart +++ b/lib/interactive_map.dart @@ -20,6 +20,7 @@ import 'package:icarus/providers/transition_provider.dart'; import 'package:icarus/widgets/dot_painter.dart'; import 'package:icarus/widgets/canonical_map_artwork.dart'; +import 'package:icarus/widgets/canvas_selection_layer.dart'; import 'package:icarus/widgets/drawing_painter.dart'; import 'package:icarus/widgets/draggable_widgets/placed_widget_builder.dart'; import 'package:icarus/widgets/delete_area.dart'; @@ -268,240 +269,246 @@ class _InteractiveMapState extends ConsumerState { ); }, child: EditorCanvasRegion( - child: SizedBox( - width: worldWidth, - height: height, - child: Stack( - clipBehavior: Clip.none, - children: [ - // Dot Grid - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - ref - .read(abilityBarProvider.notifier) - .updateData(null); - }, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: RepaintBoundary( - child: DotGrid( - followsEditorZoom: true)), - ), - ), - ), - // Map SVG - Positioned( - left: mapLeft, - top: 0, - width: mapWidth, - height: height, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - ref - .read(abilityBarProvider.notifier) - .updateData(null); - }, - child: RepaintBoundary( - child: CanonicalMapArtwork( - map: - ref.watch(mapProvider).currentMap, - isAttack: isAttack, - child: SvgPicture.asset( - assetName, - colorMapper: mapColorMapper, - semanticsLabel: 'Map', - fit: BoxFit.contain, - ), - ), - ), - ), - ), - // View cones: the map as they reveal it, - // shown wherever one reaches. - Positioned.fill( - child: IgnorePointer( - child: ConeCoverageLayer( - coverage: _cones, - floor: _revealedFloor(isAttack), - opacity: ref - .watch(interactionStateProvider) - .isLineUpMode - ? 0.2 - : 1.0, - child: Stack( - children: [ - Positioned( - left: mapLeft, - top: 0, - width: mapWidth, - height: height, - child: CanonicalMapArtwork( - map: ref - .watch(mapProvider) - .currentMap, - isAttack: isAttack, - child: SvgPicture.asset( - assetName, - colorMapper: MapSvgColorMapper - .revealed( - effectivePalette), - fit: BoxFit.contain, - ), - ), - ), - ], + child: CanvasSelectionLayer( + enabled: canEditPages && + !transitionPresentation.active && + ref.watch(interactionStateProvider) == + InteractionState.navigation, + child: SizedBox( + width: worldWidth, + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Dot Grid + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + ref + .read(abilityBarProvider.notifier) + .updateData(null); + }, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: RepaintBoundary( + child: DotGrid( + followsEditorZoom: true)), ), ), ), - ), - if (ref.watch(mapProvider).showSpawnBarrier) + // Map SVG Positioned( left: mapLeft, top: 0, width: mapWidth, height: height, - child: Transform.flip( - flipX: !isAttack, - flipY: !isAttack, - child: SvgPicture.asset( - barrierAssetName, - semanticsLabel: 'Barrier', - fit: BoxFit.contain, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + ref + .read(abilityBarProvider.notifier) + .updateData(null); + }, + child: RepaintBoundary( + child: CanonicalMapArtwork( + map: + ref.watch(mapProvider).currentMap, + isAttack: isAttack, + child: SvgPicture.asset( + assetName, + colorMapper: mapColorMapper, + semanticsLabel: 'Map', + fit: BoxFit.contain, + ), + ), ), ), ), - if (ref.watch(mapProvider).showRegionNames) - Positioned( - left: mapLeft, - top: 0, - width: mapWidth, - height: height, - child: SvgPicture.asset( - calloutsAssetName, - semanticsLabel: 'Callouts', - fit: BoxFit.contain, + // View cones: the map as they reveal it, + // shown wherever one reaches. + Positioned.fill( + child: IgnorePointer( + child: ConeCoverageLayer( + coverage: _cones, + floor: _revealedFloor(isAttack), + opacity: ref + .watch(interactionStateProvider) + .isLineUpMode + ? 0.2 + : 1.0, + child: Stack( + children: [ + Positioned( + left: mapLeft, + top: 0, + width: mapWidth, + height: height, + child: CanonicalMapArtwork( + map: ref + .watch(mapProvider) + .currentMap, + isAttack: isAttack, + child: SvgPicture.asset( + assetName, + colorMapper: MapSvgColorMapper + .revealed( + effectivePalette), + fit: BoxFit.contain, + ), + ), + ), + ], + ), + ), ), ), - if (ref.watch(mapProvider).showUltOrbs) - Positioned( - left: mapLeft, - top: 0, - width: mapWidth, - height: height, - child: Transform.flip( - flipX: !isAttack, - flipY: !isAttack, + if (ref.watch(mapProvider).showSpawnBarrier) + Positioned( + left: mapLeft, + top: 0, + width: mapWidth, + height: height, + child: Transform.flip( + flipX: !isAttack, + flipY: !isAttack, + child: SvgPicture.asset( + barrierAssetName, + semanticsLabel: 'Barrier', + fit: BoxFit.contain, + ), + ), + ), + if (ref.watch(mapProvider).showRegionNames) + Positioned( + left: mapLeft, + top: 0, + width: mapWidth, + height: height, child: SvgPicture.asset( - ultOrbsAssetName, - semanticsLabel: 'Ult Orbs', + calloutsAssetName, + semanticsLabel: 'Callouts', fit: BoxFit.contain, ), ), + if (ref.watch(mapProvider).showUltOrbs) + Positioned( + left: mapLeft, + top: 0, + width: mapWidth, + height: height, + child: Transform.flip( + flipX: !isAttack, + flipY: !isAttack, + child: SvgPicture.asset( + ultOrbsAssetName, + semanticsLabel: 'Ult Orbs', + fit: BoxFit.contain, + ), + ), + ), + Positioned.fill( + // PlacedWidgetBuilder keeps a viewer's + // pointer off everything but lineups. + child: ExcludeFocus( + excluding: !canEditPages, + child: transitionPresentation.hideView + ? SizedBox.shrink() + : Opacity( + opacity: ref + .watch( + interactionStateProvider) + .isLineUpMode + ? 0.2 + : 1.0, + child: ConeCoverageScope( + coverage: _cones, + child: PlacedWidgetBuilder()), + ), + ), ), - Positioned.fill( - // PlacedWidgetBuilder keeps a viewer's - // pointer off everything but lineups. - child: ExcludeFocus( - excluding: !canEditPages, + Positioned.fill( child: transitionPresentation.hideView - ? SizedBox.shrink() - : Opacity( - opacity: ref - .watch( - interactionStateProvider) - .isLineUpMode - ? 0.2 - : 1.0, + ? IgnorePointer( + child: LineUpOverlay(), + ) + : SizedBox.shrink(), + ), + Positioned.fill( + child: transitionPresentation.active + ? RepaintBoundary( child: ConeCoverageScope( coverage: _cones, - child: PlacedWidgetBuilder()), - ), + child: PageTransitionOverlay()), + ) + : SizedBox.shrink(), ), - ), - Positioned.fill( - child: transitionPresentation.hideView - ? IgnorePointer( - child: LineUpOverlay(), - ) - : SizedBox.shrink(), - ), - Positioned.fill( - child: transitionPresentation.active - ? RepaintBoundary( - child: ConeCoverageScope( - coverage: _cones, - child: PageTransitionOverlay()), - ) - : SizedBox.shrink(), - ), - Positioned.fill( - child: transitionPresentation.hideView && - transitionPresentation.phase == - PageTransitionPhase.preparing - ? ConeCoverageScope( - coverage: _cones, - child: TemporaryWidgetBuilder()) - : SizedBox.shrink(), - ), - // Painting - Positioned.fill( - // Nested Consumer so the per-frame fade-in - // progress only rebuilds the drawing layer. - child: Consumer( - builder: (context, ref, _) { - final transitionOpacity = ref.watch( - drawingsTransitionOpacityProvider, - ); - final lineUpOpacity = ref - .watch(interactionStateProvider) - .isLineUpMode - ? 0.2 - : 1.0; - return IgnorePointer( - key: const ValueKey( - 'strategy-canvas-drawing-editor', - ), - ignoring: !canEditPages, - child: Opacity( - opacity: transitionOpacity * - lineUpOpacity, - child: Transform.flip( - flipX: !isAttack, - flipY: !isAttack, - child: InteractivePainter()), - ), - ); - }, + Positioned.fill( + child: transitionPresentation.hideView && + transitionPresentation.phase == + PageTransitionPhase.preparing + ? ConeCoverageScope( + coverage: _cones, + child: TemporaryWidgetBuilder()) + : SizedBox.shrink(), ), - ), - if (ref.watch(interactionStateProvider) == - InteractionState.lineUpPlacing) + // Painting Positioned.fill( - child: IgnorePointer( - ignoring: !canEditPages, - child: const LineupPositionWidget(), + // Nested Consumer so the per-frame fade-in + // progress only rebuilds the drawing layer. + child: Consumer( + builder: (context, ref, _) { + final transitionOpacity = ref.watch( + drawingsTransitionOpacityProvider, + ); + final lineUpOpacity = ref + .watch(interactionStateProvider) + .isLineUpMode + ? 0.2 + : 1.0; + return IgnorePointer( + key: const ValueKey( + 'strategy-canvas-drawing-editor', + ), + ignoring: !canEditPages, + child: Opacity( + opacity: transitionOpacity * + lineUpOpacity, + child: Transform.flip( + flipX: !isAttack, + flipY: !isAttack, + child: InteractivePainter()), + ), + ); + }, ), ), - if (ref.watch(interactionStateProvider) == - InteractionState.lineUpEditing) + if (ref.watch(interactionStateProvider) == + InteractionState.lineUpPlacing) + Positioned.fill( + child: IgnorePointer( + ignoring: !canEditPages, + child: const LineupPositionWidget(), + ), + ), + if (ref.watch(interactionStateProvider) == + InteractionState.lineUpEditing) + Positioned.fill( + child: IgnorePointer( + ignoring: !canEditPages, + child: const LineUpPlacementEditor(), + ), + ), + // Teammates' cursors ride above everything + // they point at. Positioned.fill( - child: IgnorePointer( - ignoring: !canEditPages, - child: const LineUpPlacementEditor(), + child: RemoteCursorsLayer( + coordinateSystem: coordinateSystem, + isAttack: isAttack, ), ), - // Teammates' cursors ride above everything - // they point at. - Positioned.fill( - child: RemoteCursorsLayer( - coordinateSystem: coordinateSystem, - isAttack: isAttack, - ), - ), - ], + ], + ), ), ), ), diff --git a/lib/providers/canvas_selection_provider.dart b/lib/providers/canvas_selection_provider.dart new file mode 100644 index 00000000..dfd8d192 --- /dev/null +++ b/lib/providers/canvas_selection_provider.dart @@ -0,0 +1,164 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/interaction_state_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; + +/// The kinds of thing the user can select on the canvas. A lineup is +/// selected as its link: the throw spot and landing come with it. +enum SelectableKind { agent, ability, text, image, utility, drawing, lineup } + +/// One selected item on the page on screen. +typedef SelectedItem = ({SelectableKind kind, String id}); + +/// What the user has selected on the page on screen. Selection belongs to +/// this window only: it is never saved or synced, and it empties when the +/// page, the strategy, or the tool changes. Items that leave the page (an +/// undo, a teammate's delete) leave the selection with them. +final canvasSelectionProvider = + NotifierProvider>( + CanvasSelectionNotifier.new, +); + +/// The ids of every item drawn as selected: the selected items, plus the +/// throw spots and landings of selected lineups, which are drawn as their +/// own items. +final selectedCanvasIdsProvider = Provider>((ref) { + final selection = ref.watch(canvasSelectionProvider); + if (selection.isEmpty) return const {}; + final lineupIds = { + for (final item in selection) + if (item.kind == SelectableKind.lineup) item.id, + }; + final links = lineupIds.isEmpty + ? const [] + : ref.watch(lineUpProvider.select((state) => state.links)); + return { + for (final item in selection) item.id, + for (final link in links) + if (lineupIds.contains(link.id)) ...[link.originId, link.landingId], + }; +}); + +class CanvasSelectionNotifier extends Notifier> { + @override + Set build() { + void clearOnChange(ProviderListenable provider) { + ref.listen(provider, (previous, next) { + if (previous != next) clear(); + }); + } + + clearOnChange(strategyProvider.select((state) => state.strategyId)); + clearOnChange( + strategyPageSessionProvider.select((state) => state.activePageId), + ); + clearOnChange(interactionStateProvider); + clearOnChange( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); + + void pruneOnChange(ProviderListenable provider) { + ref.listen(provider, (_, __) => _prune()); + } + + pruneOnChange(agentProvider); + pruneOnChange(abilityProvider); + pruneOnChange(textProvider); + pruneOnChange(placedImageProvider.select((state) => state.images)); + pruneOnChange(utilityProvider); + pruneOnChange(drawingProvider.select((state) => state.elements)); + pruneOnChange(lineUpProvider.select((state) => state.links)); + return const {}; + } + + /// Every item on the page on screen. + Set _everything() => { + for (final agent in ref.read(agentProvider)) + (kind: SelectableKind.agent, id: agent.id), + for (final ability in ref.read(abilityProvider)) + (kind: SelectableKind.ability, id: ability.id), + for (final text in ref.read(textProvider)) + (kind: SelectableKind.text, id: text.id), + for (final image in ref.read(placedImageProvider).images) + (kind: SelectableKind.image, id: image.id), + for (final utility in ref.read(utilityProvider)) + (kind: SelectableKind.utility, id: utility.id), + for (final drawing in ref.read(drawingProvider).elements) + (kind: SelectableKind.drawing, id: drawing.id), + for (final link in ref.read(lineUpProvider).links) + (kind: SelectableKind.lineup, id: link.id), + }; + + void _prune() { + if (state.isEmpty) return; + final present = _everything(); + final kept = state.where(present.contains).toSet(); + if (kept.length != state.length) state = kept; + } + + /// Replaces the selection with [items]. + void select(Iterable items) => state = {...items}; + + /// Adds [items] to the selection. + void add(Iterable items) => state = {...state, ...items}; + + /// Selects [item] if it isn't, and unselects it if it is. + void toggle(SelectedItem item) => state = + state.contains(item) ? ({...state}..remove(item)) : {...state, item}; + + void selectAll() => state = _everything(); + + void clear() { + if (state.isNotEmpty) state = const {}; + } + + /// Deletes everything selected as one undo step. + void deleteSelected() { + final selection = state; + if (selection.isEmpty || + !ref.read(currentStrategyCapabilitiesProvider).canEditPages) { + return; + } + ref.read(actionProvider.notifier).performTransaction( + groups: const [], + mutation: () { + final drawingIds = {}; + for (final item in selection) { + switch (item.kind) { + case SelectableKind.agent: + ref.read(agentProvider.notifier).removeAgentAsAction(item.id); + case SelectableKind.ability: + ref.read(abilityProvider.notifier).removeAbilityAsAction(item.id); + case SelectableKind.text: + ref.read(textProvider.notifier).removeTextAsAction(item.id); + case SelectableKind.image: + ref + .read(placedImageProvider.notifier) + .removeImageAsAction(item.id); + case SelectableKind.utility: + ref.read(utilityProvider.notifier).removeUtilityAsAction(item.id); + case SelectableKind.drawing: + drawingIds.add(item.id); + case SelectableKind.lineup: + ref.read(lineUpProvider.notifier).deleteLink(item.id); + } + } + if (drawingIds.isNotEmpty) { + ref.read(drawingProvider.notifier).deleteDrawingsById(drawingIds); + } + }, + ); + clear(); + } +} diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index a416fce3..ed5f5495 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -406,6 +406,15 @@ class DrawingProvider extends Notifier { _triggerRepaint(); } + /// Deletes the drawings in [ids], each recorded as its own deletion (wrap + /// the call in performTransaction for one undo step). + void deleteDrawingsById(Set ids) { + // Last first, so the indexes still to delete stay where they were. + for (final (index, drawing) in [...state.elements.indexed].reversed) { + if (ids.contains(drawing.id)) deleteDrawing(index); + } + } + void onErase(Offset mousePos) { if (!ref.canEditPages) return; final Set indicesToDelete = { diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index a23cec5a..35cebc32 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -146,6 +146,11 @@ class AppPreferences extends HiveObject { /// is a lossless .mov for editing, not an MP4 to share. final bool transparentVideoBackground; + /// Whether this install has said that a drag on the map selects and Space + /// moves the map. Stores written before selection existed read false, so + /// updating users are told once. + final bool canvasSelectionHintSeen; + AppPreferences({ required this.defaultThemeProfileIdForNewStrategies, this.autosaveEnabled = true, @@ -169,6 +174,7 @@ class AppPreferences extends HiveObject { this.welcomeTourSeen = false, this.transparentScreenshotBackground = false, this.transparentVideoBackground = false, + this.canvasSelectionHintSeen = false, }) : customColorValues = List.unmodifiable(customColorValues ?? const []), customShortcutBindings = Map.unmodifiable(customShortcutBindings ?? const {}); @@ -196,6 +202,7 @@ class AppPreferences extends HiveObject { bool? welcomeTourSeen, bool? transparentScreenshotBackground, bool? transparentVideoBackground, + bool? canvasSelectionHintSeen, }) { return AppPreferences( defaultThemeProfileIdForNewStrategies: @@ -232,6 +239,8 @@ class AppPreferences extends HiveObject { this.transparentScreenshotBackground, transparentVideoBackground: transparentVideoBackground ?? this.transparentVideoBackground, + canvasSelectionHintSeen: + canvasSelectionHintSeen ?? this.canvasSelectionHintSeen, ); } } @@ -736,6 +745,12 @@ class AppPreferencesNotifier extends Notifier { ); } + Future markCanvasSelectionHintSeen() { + return _updatePreferences( + (current) => current.copyWith(canvasSelectionHintSeen: true), + ); + } + Future setDrawingDefaults({ int? colorValue, double? thickness, diff --git a/lib/widgets/canvas_selection_layer.dart b/lib/widgets/canvas_selection_layer.dart new file mode 100644 index 00000000..527b0530 --- /dev/null +++ b/lib/widgets/canvas_selection_layer.dart @@ -0,0 +1,510 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/screen_zoom_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; + +/// Whether Space is held to move the map: while it is, a drag anywhere on +/// the canvas moves the map instead of selecting or moving what it lands on. +final canvasPanKeyHeldProvider = + NotifierProvider<_CanvasPanKeyHeld, bool>(_CanvasPanKeyHeld.new); + +class _CanvasPanKeyHeld extends Notifier { + @override + bool build() => false; + + void set(bool held) { + if (state != held) state = held; + } +} + +/// The strategy canvas in navigation mode, as a whiteboard: a drag on an +/// empty part of the map draws a box that selects what it covers, a click +/// selects an item (Shift adds or removes it), and holding Space turns +/// every drag into moving the map. [child] is the canvas, in the canvas's +/// own coordinates (the same space items are laid out in). +/// +/// The map's own pan and zoom stay with the [InteractiveViewer] around this: +/// the box is a mouse gesture, so a touch drag, a trackpad's two fingers, a +/// middle-button drag and the scroll wheel move and zoom the map as before. +/// +/// A press on the canvas gives it the keyboard, so the selection's keys +/// (Escape, Delete, Backspace, select all) and Space reach it, and never +/// fire from a dialog, a menu or the library. +class CanvasSelectionLayer extends ConsumerStatefulWidget { + const CanvasSelectionLayer({ + required this.enabled, + required this.child, + super.key, + }); + + /// Selecting is possible: the navigation tool is on and the page can be + /// edited. Space still moves the map when this is false. + final bool enabled; + + final Widget child; + + @override + ConsumerState createState() => + _CanvasSelectionLayerState(); +} + +class _CanvasSelectionLayerState extends ConsumerState + with WidgetsBindingObserver { + final _registry = CanvasItemRegistry(); + final _canvasKey = GlobalKey(); + final _focus = FocusNode(debugLabel: 'strategy-canvas', skipTraversal: true); + + /// The box being drawn, in canvas coordinates, while a drag lasts, and + /// the corner it is drawn from. + Rect? _box; + Offset? _boxOrigin; + bool _boxAdds = false; + + /// The press that may become a click: where it went down, and whether a + /// box has started from it since. + int? _clickPointer; + Offset? _clickDown; + bool _clickBecameBox = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + HardwareKeyboard.instance.addHandler(_releaseSpace); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_releaseSpace); + WidgetsBinding.instance.removeObserver(this); + _focus.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // A Space released while the window was in the background never comes. + if (state != AppLifecycleState.resumed) { + ref.read(canvasPanKeyHeldProvider.notifier).set(false); + } + } + + /// Lets go of Space wherever the keyboard is by then: a Space held on the + /// canvas can be released after a click moved the focus away. + bool _releaseSpace(KeyEvent event) { + if (event is KeyUpEvent && event.logicalKey == LogicalKeyboardKey.space) { + ref.read(canvasPanKeyHeldProvider.notifier).set(false); + } + return false; + } + + /// Whether the key goes to a text field on the canvas (a text item being + /// written), where every key keeps its usual meaning. + static bool _typing() { + final context = FocusManager.instance.primaryFocus?.context; + return context != null && + (context.widget is EditableText || + context.findAncestorStateOfType() != null); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (_typing()) return KeyEventResult.ignored; + final key = event.logicalKey; + if (key == LogicalKeyboardKey.space) { + if (event is! KeyUpEvent) { + ref.read(canvasPanKeyHeldProvider.notifier).set(true); + } + return KeyEventResult.handled; + } + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (key == LogicalKeyboardKey.escape && _box != null) { + // Escape drops the box being drawn; the selection stays as it was. + _boxOrigin = null; + setState(() => _box = null); + return KeyEventResult.handled; + } + if (!widget.enabled) return KeyEventResult.ignored; + final selection = ref.read(canvasSelectionProvider.notifier); + final hasSelection = ref.read(canvasSelectionProvider).isNotEmpty; + if (key == LogicalKeyboardKey.escape && hasSelection) { + selection.clear(); + return KeyEventResult.handled; + } + if ((key == LogicalKeyboardKey.delete || + key == LogicalKeyboardKey.backspace) && + hasSelection) { + selection.deleteSelected(); + return KeyEventResult.handled; + } + final selectAll = ShortcutInfo.effectiveBindingFor( + IcarusShortcutAction.selectAll.name, + ref.read(appPreferencesProvider).customShortcutBindings, + ).toActivator(); + if (selectAll.accepts(event, HardwareKeyboard.instance)) { + selection.selectAll(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + /// The first time a drag on the map draws a box instead of moving the map, + /// says why while the drag is still under the user's hand, and how to + /// move the map now. + void _teachOnce() { + final preferences = ref.read(appPreferencesProvider); + if (preferences.canvasSelectionHintSeen) return; + ref.read(appPreferencesProvider.notifier).markCanvasSelectionHintSeen(); + Settings.showToast( + message: 'Dragging on the map now selects. Hold Space and drag to ' + 'move the map.', + backgroundColor: Settings.tacticalVioletTheme.primary, + autoCloseDuration: const Duration(seconds: 8), + ); + } + + RenderBox? get _canvas => + _canvasKey.currentContext?.findRenderObject() as RenderBox?; + + bool get _shiftHeld => HardwareKeyboard.instance.isShiftPressed; + + void _onBoxStart(DragStartDetails details) { + _boxAdds = _shiftHeld; + _boxOrigin = details.localPosition; + } + + void _onBoxUpdate(DragUpdateDetails details) { + final origin = _boxOrigin; + if (origin == null) return; + // A box exists once the pointer has moved: a press that won the gesture + // only because nothing else wanted it is still a click. + if (!_clickBecameBox) _teachOnce(); + _clickBecameBox = true; + setState(() => _box = Rect.fromPoints(origin, details.localPosition)); + } + + void _onBoxEnd(DragEndDetails details) { + final box = _box; + _boxOrigin = null; + setState(() => _box = null); + if (box == null) return; + final found = _itemsIn(box); + final selection = ref.read(canvasSelectionProvider.notifier); + _boxAdds ? selection.add(found) : selection.select(found); + } + + void _onBoxCancel() { + _boxOrigin = null; + setState(() => _box = null); + } + + /// What [box] selects. Things that mark a spot (agents, abilities, + /// utilities, a lineup's two ends) count when their middle is inside it; + /// text, images and drawings, which cover an area, when all of them is. + Set _itemsIn(Rect box) { + final canvas = _canvas; + if (canvas == null) return const {}; + final found = {}; + final lineupEnds = {}; + for (final (:item, :corners) in _registry.itemsIn(canvas)) { + final inside = switch (item.kind) { + SelectableKind.text || + SelectableKind.image => + corners.every(box.contains), + _ => box.contains(_middle(corners)), + }; + if (!inside) continue; + if (item.kind == SelectableKind.lineup) { + lineupEnds.add(item.id); + } else { + found.add(item); + } + } + // A lineup comes in whole: both its throw spot and landing are inside. + for (final link in ref.read(lineUpProvider).links) { + if (lineupEnds.contains(link.originId) && + lineupEnds.contains(link.landingId)) { + found.add((kind: SelectableKind.lineup, id: link.id)); + } + } + for (final drawing in ref.read(drawingProvider).elements) { + final bounds = drawing.boundingBox; + if (bounds == null) continue; + if (box.contains(_drawn(bounds.min)) && + box.contains(_drawn(bounds.max))) { + found.add((kind: SelectableKind.drawing, id: drawing.id)); + } + } + return found; + } + + static Offset _middle(List corners) => + corners.reduce((a, b) => a + b) / corners.length.toDouble(); + + bool get _isAttack => ref.read(mapProvider).isAttack; + + /// Where a point stored in world coordinates is drawn on the canvas. + /// Drawings are stored attack side up and turned with the map. + Offset _drawn(Offset world) { + final coordinates = CoordinateSystem.instance; + final attack = coordinates.coordinateToScreen(world); + return _isAttack ? attack : _flip(attack, coordinates.playAreaSize); + } + + /// The world point drawn at [onCanvas]. + Offset _stored(Offset onCanvas) { + final coordinates = CoordinateSystem.instance; + final attack = + _isAttack ? onCanvas : _flip(onCanvas, coordinates.playAreaSize); + return coordinates.screenToCoordinate(attack); + } + + /// The defense side is the attack layout turned half a circle. + static Offset _flip(Offset point, Size canvas) => + canvas.bottomRight(Offset.zero) - point; + + void _onPointerDown(PointerDownEvent event) { + // The canvas takes the keyboard, except from a text item, which takes it + // itself to be written in. + final held = + ref.read(editorPointersProvider)[event.pointer]?.entityIds ?? const {}; + if (!ref.read(textProvider).any((text) => held.contains(text.id))) { + // After this press is handled: a text item being written lets go of + // the keyboard when pressed outside, and must not hand it to the page + // after the canvas took it. + scheduleMicrotask(() { + if (mounted) _focus.requestFocus(); + }); + } + if (!widget.enabled || + event.kind != PointerDeviceKind.mouse || + event.buttons != kPrimaryMouseButton || + ref.read(canvasPanKeyHeldProvider)) { + _clickPointer = null; + return; + } + _clickPointer = event.pointer; + _clickDown = event.position; + _clickBecameBox = false; + } + + void _onPointerUp(PointerUpEvent event) { + if (event.pointer != _clickPointer) return; + _clickPointer = null; + final down = _clickDown; + if (_clickBecameBox || down == null) return; + // A press that moved is a drag of something, not a click. + if ((event.position - down).distance > kTouchSlop) return; + _click(event); + } + + void _click(PointerUpEvent event) { + final selection = ref.read(canvasSelectionProvider.notifier); + final hold = ref.read(editorPointersProvider)[event.pointer]; + final hit = _clickedItem(hold?.entityIds ?? const {}, event); + if (hit == null) { + if (!_shiftHeld) selection.clear(); + return; + } + // Clicking a lineup opens it; it is selected with a box. + if (hit.kind == SelectableKind.lineup) return; + _shiftHeld ? selection.toggle(hit) : selection.select([hit]); + } + + /// The item a click landed on: the canvas item its press held, or else a + /// drawing under it (drawings are painted, so they hold nothing). + SelectedItem? _clickedItem(Set heldIds, PointerUpEvent event) { + final canvas = _canvas; + if (canvas == null) return null; + if (heldIds.isNotEmpty) { + for (final (:item, corners: _) in _registry.itemsIn(canvas)) { + if (heldIds.contains(item.id)) return item; + } + return null; + } + final onCanvas = canvas.globalToLocal(event.position); + final point = _stored(onCanvas); + final zoom = ref.read(screenZoomProvider); + final reach = CoordinateSystem.instance + .screenToCoordinate(Offset(_clickReach / zoom, 0)) + .dx; + final drawings = ref.read(drawingProvider).elements; + // The one drawn last is on top. + for (final drawing in drawings.reversed) { + if (isNearDrawing(drawing, point, reach)) { + return (kind: SelectableKind.drawing, id: drawing.id); + } + } + return null; + } + + /// How far from a stroke a click still selects it, in screen pixels. + static const double _clickReach = 6; + + /// The selected drawings' extents on the canvas. Drawings are painted, not + /// laid out, so their outline is drawn here rather than by the item. + List _selectedDrawingOutlines() { + final ids = { + for (final item in ref.watch(canvasSelectionProvider)) + if (item.kind == SelectableKind.drawing) item.id, + }; + if (ids.isEmpty) return const []; + return [ + for (final drawing in ref.watch(drawingProvider).elements) + if (ids.contains(drawing.id)) + if (drawing.boundingBox case final bounds?) + Rect.fromPoints( + _drawn(bounds.min), + _drawn(bounds.max), + ).inflate(CoordinateSystem.instance.scale(drawing.thickness) / 2), + ]; + } + + @override + Widget build(BuildContext context) { + final panHeld = ref.watch(canvasPanKeyHeldProvider); + final selecting = widget.enabled && !panHeld; + return CanvasItemRegistryScope( + registry: _registry, + child: Focus( + focusNode: _focus, + autofocus: true, + onKeyEvent: _onKey, + child: MouseRegion( + cursor: panHeld ? SystemMouseCursors.grab : MouseCursor.defer, + child: Listener( + onPointerDown: _onPointerDown, + onPointerUp: _onPointerUp, + child: RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + if (selecting) + PanGestureRecognizer: GestureRecognizerFactoryWithHandlers< + PanGestureRecognizer>( + () => PanGestureRecognizer( + supportedDevices: const {PointerDeviceKind.mouse}, + allowedButtonsFilter: (buttons) => + buttons == kPrimaryMouseButton, + ), + (recognizer) => recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _onBoxStart + ..onUpdate = _onBoxUpdate + ..onEnd = _onBoxEnd + ..onCancel = _onBoxCancel, + ), + }, + child: Stack( + key: _canvasKey, + clipBehavior: Clip.none, + children: [ + // With Space held, the press goes past the items to the + // map, which moves. + IgnorePointer(ignoring: panHeld, child: widget.child), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _SelectedDrawingsPainter( + outlines: _selectedDrawingOutlines(), + zoom: ref.watch(screenZoomProvider), + ), + ), + ), + ), + if (_box case final box?) + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _SelectionBoxPainter( + box: box, + zoom: ref.watch(screenZoomProvider), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Outlines the selected drawings the way [EditorItemOutline] outlines a +/// selected item: a rounded box a few pixels clear of it. +class _SelectedDrawingsPainter extends CustomPainter { + _SelectedDrawingsPainter({required this.outlines, required this.zoom}); + + final List outlines; + final double zoom; + + @override + void paint(Canvas canvas, Size size) { + final scale = 1 / zoom; + final paint = Paint() + ..color = Settings.tacticalVioletTheme.primary + ..style = PaintingStyle.stroke + ..strokeWidth = 2 * scale; + for (final outline in outlines) { + canvas.drawRRect( + RRect.fromRectAndRadius( + outline.inflate(4 * scale), + Radius.circular(6 * scale), + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_SelectedDrawingsPainter oldDelegate) => + zoom != oldDelegate.zoom || !_sameRects(outlines, oldDelegate.outlines); + + static bool _sameRects(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +class _SelectionBoxPainter extends CustomPainter { + _SelectionBoxPainter({required this.box, required this.zoom}); + + final Rect box; + final double zoom; + + @override + void paint(Canvas canvas, Size size) { + final violet = Settings.tacticalVioletTheme.primary; + canvas.drawRect(box, Paint()..color = violet.withValues(alpha: 0.12)); + canvas.drawRect( + box, + Paint() + ..color = violet + ..style = PaintingStyle.stroke + // One pixel on screen at any zoom. + ..strokeWidth = 1 / zoom, + ); + } + + @override + bool shouldRepaint(_SelectionBoxPainter oldDelegate) => + box != oldDelegate.box || zoom != oldDelegate.zoom; +} diff --git a/lib/widgets/draggable_widgets/placed_widget_builder.dart b/lib/widgets/draggable_widgets/placed_widget_builder.dart index 6dba21fd..f6d747c1 100644 --- a/lib/widgets/draggable_widgets/placed_widget_builder.dart +++ b/lib/widgets/draggable_widgets/placed_widget_builder.dart @@ -42,6 +42,7 @@ import 'package:icarus/widgets/draggable_widgets/utilities/placed_view_cone_widg import 'package:icarus/const/utilities.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; import 'package:icarus/widgets/line_up_line_painter.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:icarus/widgets/line_up_widget.dart'; import 'package:uuid/uuid.dart'; @@ -491,6 +492,7 @@ class _AbilityList extends ConsumerWidget { EditorEntityLayer( key: ValueKey('entity-${ability.id}'), id: ability.id, + kind: SelectableKind.ability, child: PlacedAbilityWidget( key: ValueKey(ability.id), rotation: ability.rotation, @@ -577,6 +579,7 @@ class _AgentListState extends ConsumerState<_AgentList> { EditorEntityLayer( key: ValueKey('entity-${agent.id}'), id: agent.id, + kind: SelectableKind.agent, child: switch (agent) { PlacedAgent() => Positioned( key: ValueKey(agent.id), @@ -744,6 +747,7 @@ class _TextList extends ConsumerWidget { EditorEntityLayer( key: ValueKey('entity-${placedText.id}'), id: placedText.id, + kind: SelectableKind.text, child: PlacedTextBuilder( key: ValueKey(placedText.id), size: placedText.size, @@ -807,6 +811,7 @@ class _PlacedImageList extends ConsumerWidget { EditorEntityLayer( key: ValueKey('entity-${placedImage.id}'), id: placedImage.id, + kind: SelectableKind.image, child: PlacedImageBuilder( key: ValueKey(placedImage.id), placedImage: placedImage, @@ -873,6 +878,7 @@ class _ViewConeUtilityList extends ConsumerWidget { EditorEntityLayer( key: ValueKey('entity-${placedUtility.id}'), id: placedUtility.id, + kind: SelectableKind.utility, child: PlacedViewConeWidget( key: ValueKey(placedUtility.id), utility: placedUtility, @@ -947,6 +953,7 @@ class _UtilityList extends ConsumerWidget { EditorEntityLayer( key: ValueKey('entity-${placedUtility.id}'), id: placedUtility.id, + kind: SelectableKind.utility, child: Positioned( key: ValueKey(placedUtility.id), left: screenPositionForWidget( @@ -1033,6 +1040,7 @@ class _CustomShapeUtilityList extends ConsumerWidget { EditorEntityLayer( key: ValueKey('entity-${placedUtility.id}'), id: placedUtility.id, + kind: SelectableKind.utility, child: Positioned( key: ValueKey('custom-shape-${placedUtility.id}'), left: screenPositionForWidget( @@ -1209,6 +1217,7 @@ class _LineUpAgents extends ConsumerWidget { EditorEntityLayer( key: ValueKey('entity-${origin.id}'), id: origin.id, + kind: SelectableKind.lineup, child: LineUpOriginAgentWidget(origin: origin), ), ], @@ -1237,6 +1246,7 @@ class _LineUpAbilities extends ConsumerWidget { EditorEntityLayer( key: ValueKey('entity-${landing.id}'), id: landing.id, + kind: SelectableKind.lineup, child: LineUpLandingAbilityWidget(landing: landing), ), ], diff --git a/lib/widgets/editor_operation_scope.dart b/lib/widgets/editor_operation_scope.dart index 000bcb8b..09fd71f2 100644 --- a/lib/widgets/editor_operation_scope.dart +++ b/lib/widgets/editor_operation_scope.dart @@ -1,6 +1,8 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/providers/collab/editing_presence_provider.dart'; import 'package:icarus/providers/editor_operation_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; @@ -100,15 +102,25 @@ class EditorCanvasRegion extends ConsumerWidget { /// One canvas item, as a child of the canvas's item [Stack]. While a press on /// it (a drag, a rotate or resize handle) lasts, remote changes to this item /// wait; the rest of the page updates. While a teammate is changing it, its -/// [EditorItemOutline] is drawn in their color. +/// [EditorItemOutline] is drawn in their color, and while it is selected, in +/// the selection's. /// /// The item sits in its own full-size layer so its positioned widget lays out /// exactly as before, and the listener only fires when the item itself is hit. class EditorEntityLayer extends ConsumerWidget { - const EditorEntityLayer({required this.id, required this.child, super.key}); + const EditorEntityLayer({ + required this.id, + required this.kind, + required this.child, + super.key, + }); final String id; + /// What the item is, for selecting it. A lineup's throw spot and landing + /// are [SelectableKind.lineup] under their own ids. + final SelectableKind kind; + /// A [Positioned] (or similar) item widget. final Widget child; @@ -123,6 +135,7 @@ class EditorEntityLayer extends ConsumerWidget { onPointerPanZoomStart: hold, child: ItemOutlineScope( id: id, + kind: kind, child: Stack(clipBehavior: Clip.none, children: [child]), ), ), @@ -131,46 +144,129 @@ class EditorEntityLayer extends ConsumerWidget { } /// Draws the [EditorItemOutline]s under it in the color of the teammate -/// changing item [id], if anyone is. +/// changing item [id], if anyone is, and in the selection's color while the +/// item is selected. With a [kind], its outline is also what a selection box +/// on the canvas measures the item by (see [CanvasItemRegistry]). class ItemOutlineScope extends ConsumerWidget { - const ItemOutlineScope({required this.id, required this.child, super.key}); + const ItemOutlineScope({ + required this.id, + required this.child, + this.kind, + super.key, + }); final String id; + final SelectableKind? kind; final Widget child; @override Widget build(BuildContext context, WidgetRef ref) { final editor = ref.watch(itemEditorsProvider.select((items) => items[id])); + final selected = kind != null && + ref.watch(selectedCanvasIdsProvider.select((ids) => ids.contains(id))); + final outlined = editor != null || selected; return _ItemOutlineStyle( + item: kind == null ? null : (kind: kind!, id: id), color: editor == null ? null : presenceColorFor(editor.uid), + selected: selected, // The outline keeps its width on screen at any zoom; only an outlined // item rebuilds as the zoom changes. - zoom: editor == null ? 1 : ref.watch(screenZoomProvider), + zoom: outlined ? ref.watch(screenZoomProvider) : 1, child: child, ); } } -/// How the [EditorItemOutline]s of one item draw: in [color], or not at all -/// when it is null. +/// How the [EditorItemOutline]s of one item draw: in [color] for the +/// teammate changing it, and in the selection's color when [selected]; not +/// at all when neither. class _ItemOutlineStyle extends InheritedWidget { const _ItemOutlineStyle({ + required this.item, required this.color, + required this.selected, required this.zoom, required super.child, }); + final SelectedItem? item; final Color? color; + final bool selected; final double zoom; @override bool updateShouldNotify(_ItemOutlineStyle oldWidget) => - color != oldWidget.color || zoom != oldWidget.zoom; + item != oldWidget.item || + color != oldWidget.color || + selected != oldWidget.selected || + zoom != oldWidget.zoom; +} + +/// The items drawn on the canvas, each measured by the outline of what it +/// draws, so a selection box finds them where the user sees them: on either +/// side of the map, at any agent or ability size, turned however far. +class CanvasItemRegistry { + final _outlines = {}; + + /// Each item on the canvas with its outline's corners in the coordinates + /// of [canvas], in order around the outline (a turned item gives a turned + /// rectangle). + Iterable<({SelectedItem item, List corners})> itemsIn( + RenderBox canvas, + ) sync* { + for (final outline in _outlines) { + final item = outline._item; + if (item == null || outline._inner > 0 || !outline.hasSize) continue; + if (!_isUnder(outline, canvas)) continue; + final transform = outline.getTransformTo(canvas); + final rect = Offset.zero & outline.size; + yield ( + item: item, + corners: [ + for (final corner in [ + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft, + ]) + MatrixUtils.transformPoint(transform, corner), + ], + ); + } + } + + static bool _isUnder(RenderObject node, RenderObject ancestor) { + for (RenderObject? at = node; at != null; at = at.parent) { + if (identical(at, ancestor)) return true; + } + return false; + } +} + +/// Hands the canvas's [CanvasItemRegistry] to the [EditorItemOutline]s +/// under it. Outlines with no registry above them (captures, drag +/// feedback) can't be selected. +class CanvasItemRegistryScope extends InheritedWidget { + const CanvasItemRegistryScope({ + required this.registry, + required super.child, + super.key, + }); + + final CanvasItemRegistry registry; + + static CanvasItemRegistry? maybeOf(BuildContext context) => context + .getInheritedWidgetOfExactType() + ?.registry; + + @override + bool updateShouldNotify(CanvasItemRegistryScope oldWidget) => + registry != oldWidget.registry; } /// The shape a canvas item draws, which is outlined while a teammate is -/// changing the item. It sits inside the item's rotation, so the outline -/// turns with it. Outside an [ItemOutlineScope] (a drag's feedback, an +/// changing the item or while it is selected. It sits inside the item's +/// rotation, so the outline turns with it. Outside an [ItemOutlineScope] (a drag's feedback, an /// export) it draws nothing. /// /// Every item marks what it draws. An item that lays out in a box larger @@ -185,7 +281,10 @@ class EditorItemOutline extends SingleChildRenderObjectWidget { context.dependOnInheritedWidgetOfExactType<_ItemOutlineStyle>(); return RenderEditorItemOutline( color: style?.color, + selected: style?.selected ?? false, zoom: style?.zoom ?? 1, + item: style?.item, + registry: CanvasItemRegistryScope.maybeOf(context), ); } @@ -198,14 +297,25 @@ class EditorItemOutline extends SingleChildRenderObjectWidget { context.dependOnInheritedWidgetOfExactType<_ItemOutlineStyle>(); renderObject ..color = style?.color - ..zoom = style?.zoom ?? 1; + ..selected = style?.selected ?? false + ..zoom = style?.zoom ?? 1 + ..item = style?.item + ..registry = CanvasItemRegistryScope.maybeOf(context); } } class RenderEditorItemOutline extends RenderProxyBox { - RenderEditorItemOutline({required Color? color, required double zoom}) - : _color = color, - _zoom = zoom; + RenderEditorItemOutline({ + required Color? color, + required bool selected, + required double zoom, + SelectedItem? item, + CanvasItemRegistry? registry, + }) : _color = color, + _selected = selected, + _zoom = zoom, + _item = item, + _registry = registry; /// On screen, in logical pixels. static const double _width = 2; @@ -219,6 +329,13 @@ class RenderEditorItemOutline extends RenderProxyBox { markNeedsPaint(); } + bool _selected; + set selected(bool value) { + if (value == _selected) return; + _selected = value; + markNeedsPaint(); + } + double _zoom; set zoom(double value) { if (value == _zoom) return; @@ -226,6 +343,19 @@ class RenderEditorItemOutline extends RenderProxyBox { markNeedsPaint(); } + SelectedItem? _item; + set item(SelectedItem? value) => _item = value; + + CanvasItemRegistry? _registry; + set registry(CanvasItemRegistry? value) { + if (identical(value, _registry)) return; + if (attached) { + _registry?._outlines.remove(this); + value?._outlines.add(this); + } + _registry = value; + } + /// The mark this one is inside, and how many marks are inside this one. RenderEditorItemOutline? _outer; int _inner = 0; @@ -244,10 +374,12 @@ class RenderEditorItemOutline extends RenderProxyBox { } _outer = node as RenderEditorItemOutline?; _outer?._changeInner(1); + _registry?._outlines.add(this); } @override void detach() { + _registry?._outlines.remove(this); _outer?._changeInner(-1); _outer = null; super.detach(); @@ -256,18 +388,28 @@ class RenderEditorItemOutline extends RenderProxyBox { @override void paint(PaintingContext context, Offset offset) { super.paint(context, offset); - final color = _color; - if (color == null || _inner > 0) return; + if (_inner > 0) return; final scale = 1 / _zoom; - context.canvas.drawRRect( - RRect.fromRectAndRadius( - (offset & size).inflate((_gap + _width / 2) * scale), - Radius.circular(_radius * scale), - ), - Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = _width * scale, - ); + var gap = _gap; + void outline(Color color) { + context.canvas.drawRRect( + RRect.fromRectAndRadius( + (offset & size).inflate((gap + _width / 2) * scale), + Radius.circular((_radius + gap - _gap) * scale), + ), + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _width * scale, + ); + // A teammate's outline goes around the selection's, so both show. + gap += _width + _gap; + } + + // Only the canvas shows a selection; a capture of the page never does. + if (_selected && _registry != null) { + outline(Settings.tacticalVioletTheme.primary); + } + if (_color case final color?) outline(color); } } diff --git a/lib/widgets/global_shortcuts.dart b/lib/widgets/global_shortcuts.dart index dd21d740..0bd58c5e 100644 --- a/lib/widgets/global_shortcuts.dart +++ b/lib/widgets/global_shortcuts.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/duplicate_drag_modifier_provider.dart'; @@ -180,6 +181,12 @@ class _GlobalShortcutsState extends ConsumerState ContextualDeleteIntent: CallbackAction( onInvoke: (intent) { if (!capabilities.canEditPages) return null; + // What the user selected goes before what the cursor is on. + if (ref.read(canvasSelectionProvider).isNotEmpty) { + _dismissDeleteMenu(); + ref.read(canvasSelectionProvider.notifier).deleteSelected(); + return null; + } final hoveredTarget = ref.read(hoveredDeleteTargetProvider); if (hoveredTarget != null) { _dismissDeleteMenu(); diff --git a/test/canvas_selection_layer_test.dart b/test/canvas_selection_layer_test.dart new file mode 100644 index 00000000..b6dccb9a --- /dev/null +++ b/test/canvas_selection_layer_test.dart @@ -0,0 +1,239 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/canvas_selection_layer.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; + +class _NoopStrategyProvider extends StrategyProvider { + @override + StrategyState build() { + return const StrategyState( + strategyId: 'test-strategy', + strategyName: null, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ); + } + + @override + void setUnsaved() {} +} + +/// An item drawn as a 20 px square with its top-left at [at], the way the +/// canvas draws one: in its own layer, marking what it draws. +Widget _item(String id, Offset at) => EditorEntityLayer( + id: id, + kind: SelectableKind.agent, + child: Positioned( + left: at.dx, + top: at.dy, + child: const EditorItemOutline( + child: SizedBox.square( + dimension: 20, + child: ColoredBox(color: Colors.white), + ), + ), + ), + ); + +const _a = (kind: SelectableKind.agent, id: 'a'); +const _b = (kind: SelectableKind.agent, id: 'b'); +const _c = (kind: SelectableKind.agent, id: 'c'); + +void main() { + late ProviderContainer container; + late Directory tempDir; + + setUpAll(() { + registerIcarusAdapters(Hive); + CoordinateSystem(playAreaSize: const Size(800, 450)); + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-selection-'); + Hive.init(tempDir.path); + // Told already: the first-open hint would write preferences mid-test. + await Hive.openBox(HiveBoxNames.appPreferencesBox).then( + (box) => box.put( + MapThemeProfilesProvider.appPreferencesSingletonKey, + AppPreferences( + defaultThemeProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfile.id, + canvasSelectionHintSeen: true, + ), + ), + ); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + Future pumpCanvas(WidgetTester tester) async { + container = ProviderContainer( + overrides: [strategyProvider.overrideWith(_NoopStrategyProvider.new)], + ); + addTearDown(container.dispose); + container.read(agentProvider.notifier).fromHive([ + for (final id in ['a', 'b', 'c']) + PlacedAgent(id: id, type: AgentType.jett, position: Offset.zero), + ]); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: CanvasSelectionLayer( + enabled: true, + child: SizedBox( + width: 800, + height: 450, + child: Stack( + children: [ + _item('a', const Offset(100, 100)), + _item('b', const Offset(200, 100)), + _item('c', const Offset(400, 300)), + ], + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + Future mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final gesture = await tester.startGesture( + from, + kind: PointerDeviceKind.mouse, + buttons: kPrimaryMouseButton, + ); + for (var i = 1; i <= 10; i++) { + await gesture.moveTo(Offset.lerp(from, to, i / 10)!); + await tester.pump(); + } + await gesture.up(); + await tester.pump(); + } + + testWidgets('a box selects the items whose middle it covers', ( + tester, + ) async { + await pumpCanvas(tester); + + // Covers a's middle (110, 110) and b's (210, 110), not c's. + await mouseDrag(tester, const Offset(50, 50), const Offset(250, 200)); + + expect(container.read(canvasSelectionProvider), {_a, _b}); + }); + + testWidgets('a touch drag draws no box: touch still moves the map', ( + tester, + ) async { + await pumpCanvas(tester); + + final gesture = await tester.startGesture( + const Offset(50, 50), + kind: PointerDeviceKind.touch, + ); + for (var i = 1; i <= 10; i++) { + await gesture.moveTo( + Offset.lerp(const Offset(50, 50), const Offset(250, 200), i / 10)!, + ); + await tester.pump(); + } + expect( + find.byWidgetPredicate( + (widget) => + widget is CustomPaint && + widget.painter.runtimeType.toString() == '_SelectionBoxPainter', + ), + findsNothing, + ); + await gesture.up(); + await tester.pump(); + + expect(container.read(canvasSelectionProvider), isEmpty); + }); + + testWidgets('a box drawn up and to the left selects the same', ( + tester, + ) async { + await pumpCanvas(tester); + + await mouseDrag(tester, const Offset(250, 200), const Offset(50, 50)); + + expect(container.read(canvasSelectionProvider), {_a, _b}); + }); + + testWidgets('Shift adds with a click, a click on nothing clears', ( + tester, + ) async { + await pumpCanvas(tester); + + await tester.tapAt(const Offset(110, 110), kind: PointerDeviceKind.mouse); + await tester.pump(); + expect(container.read(canvasSelectionProvider), {_a}); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.tapAt(const Offset(410, 310), kind: PointerDeviceKind.mouse); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(container.read(canvasSelectionProvider), {_a, _c}); + + await tester.tapAt(const Offset(600, 50), kind: PointerDeviceKind.mouse); + await tester.pump(); + expect(container.read(canvasSelectionProvider), isEmpty); + }); + + testWidgets('with Space held a drag selects nothing', (tester) async { + await pumpCanvas(tester); + // The canvas has the keyboard once it is pressed. + await tester.tapAt(const Offset(600, 50), kind: PointerDeviceKind.mouse); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await mouseDrag(tester, const Offset(50, 50), const Offset(250, 200)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + + expect(container.read(canvasSelectionProvider), isEmpty); + expect(container.read(canvasPanKeyHeldProvider), isFalse); + }); + + testWidgets('Escape clears the selection, select all takes the page', ( + tester, + ) async { + await pumpCanvas(tester); + await mouseDrag(tester, const Offset(50, 50), const Offset(250, 200)); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(container.read(canvasSelectionProvider), isEmpty); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect(container.read(canvasSelectionProvider), {_a, _b, _c}); + }); +} diff --git a/test/canvas_selection_test.dart b/test/canvas_selection_test.dart new file mode 100644 index 00000000..9c0b6058 --- /dev/null +++ b/test/canvas_selection_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/interaction_state_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _NoopStrategyProvider extends StrategyProvider { + @override + StrategyState build() { + return const StrategyState( + strategyId: 'test-strategy', + strategyName: null, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ); + } + + @override + void setUnsaved() {} +} + +ProviderContainer _container() { + final container = ProviderContainer( + overrides: [strategyProvider.overrideWith(_NoopStrategyProvider.new)], + ); + addTearDown(container.dispose); + // Built before the page changes under it, as the canvas builds it. + container.listen(canvasSelectionProvider, (_, __) {}); + return container; +} + +PlacedAgent _agent(String id, Offset position) => + PlacedAgent(id: id, type: AgentType.jett, position: position); + +Line _line(String id) => Line( + lineStart: const Offset(100, 100), + lineEnd: const Offset(200, 200), + color: Colors.white, + boundingBox: null, + isDotted: false, + hasArrow: false, + id: id, + ); + +const _a = (kind: SelectableKind.agent, id: 'a'); +const _b = (kind: SelectableKind.agent, id: 'b'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); + + test('deleting a selection undoes in one step', () { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + _agent('a', const Offset(10, 10)), + _agent('b', const Offset(20, 20)), + _agent('c', const Offset(30, 30)), + ]); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 't', position: const Offset(40, 40)), + ]); + container.read(drawingProvider.notifier).fromHive([_line('d')]); + final selection = container.read(canvasSelectionProvider.notifier); + + selection.select([ + _a, + _b, + (kind: SelectableKind.text, id: 't'), + (kind: SelectableKind.drawing, id: 'd'), + ]); + selection.deleteSelected(); + + expect(container.read(agentProvider).map((agent) => agent.id), ['c']); + expect(container.read(textProvider), isEmpty); + expect(container.read(drawingProvider).elements, isEmpty); + expect(container.read(canvasSelectionProvider), isEmpty); + + container.read(actionProvider.notifier).undoAction(); + + expect( + container.read(agentProvider).map((agent) => agent.id), + unorderedEquals(['a', 'b', 'c']), + ); + expect(container.read(textProvider).map((text) => text.id), ['t']); + expect( + container.read(drawingProvider).elements.map((drawing) => drawing.id), + ['d'], + ); + }); + + test('an item that leaves the page leaves the selection', () { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + _agent('a', const Offset(10, 10)), + _agent('b', const Offset(20, 20)), + ]); + container.read(canvasSelectionProvider.notifier).select([_a, _b]); + + container.read(agentProvider.notifier).removeAgentAsAction('a'); + + expect(container.read(canvasSelectionProvider), {_b}); + }); + + test('changing tools clears the selection', () { + final container = _container(); + container + .read(agentProvider.notifier) + .fromHive([_agent('a', const Offset(10, 10))]); + container.read(canvasSelectionProvider.notifier).select([_a]); + + container + .read(interactionStateProvider.notifier) + .update(InteractionState.drawing); + + expect(container.read(canvasSelectionProvider), isEmpty); + }); + + test('select all takes every item on the page, toggle takes one back', () { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + _agent('a', const Offset(10, 10)), + _agent('b', const Offset(20, 20)), + ]); + container.read(drawingProvider.notifier).fromHive([_line('d')]); + final selection = container.read(canvasSelectionProvider.notifier); + + selection.selectAll(); + selection.toggle(_a); + + expect(container.read(canvasSelectionProvider), { + _b, + (kind: SelectableKind.drawing, id: 'd'), + }); + }); +} diff --git a/test/editor_operation_scope_test.dart b/test/editor_operation_scope_test.dart index 2f56ebea..b140d7fb 100644 --- a/test/editor_operation_scope_test.dart +++ b/test/editor_operation_scope_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/widgets/editor_operation_scope.dart'; void main() { @@ -161,6 +162,7 @@ void main() { EditorEntityLayer( key: ValueKey(id), id: id, + kind: SelectableKind.agent, child: Positioned( left: left, top: 0, diff --git a/test/widgets/editor_entity_outline_test.dart b/test/widgets/editor_entity_outline_test.dart index afe6e67e..0fc28bd5 100644 --- a/test/widgets/editor_entity_outline_test.dart +++ b/test/widgets/editor_entity_outline_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/presence/presence_models.dart'; import 'package:icarus/providers/collab/editing_presence_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:icarus/widgets/strategy_presence.dart'; @@ -61,7 +62,13 @@ void main() { await _pump( tester, canvas: const Stack( - children: [EditorEntityLayer(id: 'item', child: _item)], + children: [ + EditorEntityLayer( + id: 'item', + kind: SelectableKind.agent, + child: _item, + ) + ], ), editors: {'other': _teammate}, ); @@ -75,7 +82,13 @@ void main() { await _pump( tester, canvas: const Stack( - children: [EditorEntityLayer(id: 'item', child: _item)], + children: [ + EditorEntityLayer( + id: 'item', + kind: SelectableKind.agent, + child: _item, + ) + ], ), editors: {'item': _teammate}, ); @@ -101,7 +114,13 @@ void main() { await _pump( tester, canvas: const Stack( - children: [EditorEntityLayer(id: 'item', child: _item)], + children: [ + EditorEntityLayer( + id: 'item', + kind: SelectableKind.agent, + child: _item, + ) + ], ), editors: {'item': _teammate}, zoom: 2, @@ -130,6 +149,7 @@ void main() { children: [ EditorEntityLayer( id: 'item', + kind: SelectableKind.agent, child: Positioned( left: 100, top: 50,