diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index 19ed0745..3818146c 100644 --- a/lib/interactive_map.dart +++ b/lib/interactive_map.dart @@ -10,6 +10,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/canvas_resize_provider.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/map_provider.dart'; @@ -150,6 +151,8 @@ class _InteractiveMapState extends ConsumerState { @override Widget build(BuildContext context) { + // A drag of the selection moves the cones without a new layout. + ref.listen(canvasGroupDragProvider, (_, __) => _cones.moved()); bool isAttack = ref.watch(mapProvider).isAttack; final canEditPages = ref.watch( currentStrategyCapabilitiesProvider.select( diff --git a/lib/providers/action_history_models.dart b/lib/providers/action_history_models.dart index d340a304..f8fd0417 100644 --- a/lib/providers/action_history_models.dart +++ b/lib/providers/action_history_models.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/bounding_box.dart'; import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/drawing_provider.dart'; class ActionObjectState { final String id; @@ -85,22 +86,23 @@ class ActionObjectState { } /// The object as plain JSON, the same fields live sync sends. Only kinds - /// that record field edits have one; drawings are only added and removed, - /// and lineups keep their own history. + /// that record field edits have one; lineups keep their own history. Map toJson() { final Object encoded = switch (kind) { ActionObjectKind.agent => agent!.toJson(), ActionObjectKind.ability => ability!.toJson(), + ActionObjectKind.drawing => DrawingProvider.elementToJson(drawing!), ActionObjectKind.text => text!.toJson(), ActionObjectKind.image => image!.toJson(), ActionObjectKind.utility => utility!.toJson(), - ActionObjectKind.drawing || ActionObjectKind.lineUp => throw UnsupportedError('$kind records no field edits'), }; return jsonDecode(jsonEncode(encoded)) as Map; } + /// The object [toJson] wrote. A free drawing comes back without its + /// screen path; whoever puts it on the canvas rebuilds it. factory ActionObjectState.fromJson( ActionObjectKind kind, Map json, @@ -110,13 +112,14 @@ class ActionObjectState { ActionObjectState.agent(PlacedAgentNode.fromJson(json)), ActionObjectKind.ability => ActionObjectState.ability(PlacedAbility.fromJson(json)), + ActionObjectKind.drawing => + ActionObjectState.drawing(DrawingProvider.elementFromJson(json)), ActionObjectKind.text => ActionObjectState.text(PlacedText.fromJson(json)), ActionObjectKind.image => ActionObjectState.image(PlacedImage.fromJson(json)), ActionObjectKind.utility => ActionObjectState.utility(PlacedUtility.fromJson(json)), - ActionObjectKind.drawing || ActionObjectKind.lineUp => throw UnsupportedError('$kind records no field edits'), }; diff --git a/lib/providers/canvas_selection_provider.dart b/lib/providers/canvas_selection_provider.dart index dfd8d192..a9c0d6b5 100644 --- a/lib/providers/canvas_selection_provider.dart +++ b/lib/providers/canvas_selection_provider.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/providers/ability_provider.dart'; @@ -48,6 +50,44 @@ final selectedCanvasIdsProvider = Provider>((ref) { }; }); +/// The ids of the selected drawings. +final selectedDrawingIdsProvider = Provider>((ref) { + return { + for (final item in ref.watch(canvasSelectionProvider)) + if (item.kind == SelectableKind.drawing) item.id, + }; +}); + +/// A drag of the selection under way: the items it carries, as they were +/// when it began, and how far it has carried them, in canvas pixels. +typedef CanvasGroupDrag = ({Set ids, Offset by}); + +/// The drag of the selection under way, or null. Its items are drawn this +/// far from where they are saved until the drag ends and moves them. +final canvasGroupDragProvider = + NotifierProvider( + CanvasGroupDragNotifier.new, +); + +class CanvasGroupDragNotifier extends Notifier { + @override + CanvasGroupDrag? build() => null; + + void set(CanvasGroupDrag? drag) => state = drag; +} + +/// Whether item [id] is one of several selected items that move together: +/// a drag on it drags them all, so its own drag stands aside. +final dragsWithSelectionProvider = Provider.family((ref, id) { + final selection = ref.watch(canvasSelectionProvider); + return selection.length > 1 && + selection.any((item) => item.id == id && movesWithSelection(item.kind)); +}); + +/// Whether [kind] moves when the selection is dragged. A lineup marks the +/// spot its screenshots were taken from, so it stays where it is. +bool movesWithSelection(SelectableKind kind) => kind != SelectableKind.lineup; + class CanvasSelectionNotifier extends Notifier> { @override Set build() { @@ -123,6 +163,61 @@ class CanvasSelectionNotifier extends Notifier> { if (state.isNotEmpty) state = const {}; } + /// Moves the items [ids] (a drag of the selection, as it was when the + /// drag began) by [by], in world coordinates, as one undo step. Lineups + /// never move this way (see [movesWithSelection]). + void moveItems(Set ids, Offset by) { + if (ids.isEmpty || + by == Offset.zero || + !ref.read(currentStrategyCapabilitiesProvider).canEditPages) { + return; + } + ref.read(actionProvider.notifier).performTransaction( + groups: const [], + mutation: () { + // In each kind's own order: a move brings an item to the front, so + // the moved items keep their order among themselves. + for (final agent in [...ref.read(agentProvider)]) { + if (!ids.contains(agent.id)) continue; + ref + .read(agentProvider.notifier) + .updatePosition(agent.position + by, agent.id); + } + for (final ability in [...ref.read(abilityProvider)]) { + if (!ids.contains(ability.id)) continue; + ref + .read(abilityProvider.notifier) + .updatePosition(ability.position + by, ability.id); + } + for (final text in [...ref.read(textProvider)]) { + if (!ids.contains(text.id)) continue; + ref + .read(textProvider.notifier) + .updatePosition(text.position + by, text.id); + } + for (final image in [...ref.read(placedImageProvider).images]) { + if (!ids.contains(image.id)) continue; + ref + .read(placedImageProvider.notifier) + .updatePosition(image.position + by, image.id); + } + for (final utility in [...ref.read(utilityProvider)]) { + if (!ids.contains(utility.id)) continue; + ref + .read(utilityProvider.notifier) + .updatePosition(utility.position + by, utility.id); + } + final drawingIds = { + for (final drawing in ref.read(drawingProvider).elements) + if (ids.contains(drawing.id)) drawing.id, + }; + if (drawingIds.isNotEmpty) { + ref.read(drawingProvider.notifier).translateDrawings(drawingIds, by); + } + }, + ); + } + /// Deletes everything selected as one undo step. void deleteSelected() { final selection = state; diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index ed5f5495..8728aedf 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -105,6 +105,8 @@ class DrawingProvider extends Notifier { _upsertDrawing(cloneDrawingElement(before)); return; case ActionType.edit: + _writeEdit(action.id, delta.undoOnto); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; @@ -138,12 +140,64 @@ class DrawingProvider extends Notifier { _removeDrawingById(action.id); return; case ActionType.edit: + _writeEdit(action.id, delta.redoOnto); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } } + /// Writes an edit onto the drawing [id] where it sits in the stack. The + /// written copy comes from JSON or history, so its screen path is rebuilt + /// for the canvas as it is now. + void _writeEdit( + String id, + ActionObjectState Function(ActionObjectState current) write, + ) { + final index = DrawingElement.getIndexByID(id, state.elements); + if (index < 0) return; + final written = + write(ActionObjectState.drawing(state.elements[index])).drawing!; + if (written is FreeDrawing) { + written.rebuildPath(CoordinateSystem.instance); + } + _upsertDrawing(written); + } + + /// Moves the drawings named in [ids] by [worldDelta] (world units, in the + /// attack-side coordinates drawings are stored in), each keeping its place + /// in the stack, and records one edit per drawing moved. A group move runs + /// this inside [ActionProvider.performTransaction] so the whole move is + /// one undo step. Nothing here keeps the drawings on the map; the caller + /// limits [worldDelta]. + void translateDrawings(Set ids, Offset worldDelta) { + if (!ref.canEditPages || ids.isEmpty || worldDelta == Offset.zero) return; + final coordinateSystem = CoordinateSystem.instance; + final newElements = [...state.elements]; + final edits = []; + for (final (index, drawing) in state.elements.indexed) { + if (!ids.contains(drawing.id)) continue; + final moved = _translated(drawing, worldDelta, coordinateSystem); + newElements[index] = moved; + edits.add(UserAction( + type: ActionType.edit, + id: drawing.id, + group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.drawing(drawing), + after: ActionObjectState.drawing(moved), + ), + )); + } + if (edits.isEmpty) return; + + state = state.copyWith(elements: newElements); + _triggerRepaint(); + final history = ref.read(actionProvider.notifier); + edits.forEach(history.addAction); + } + String toJson() { final List> jsonList = state.elements.map(_serializeDrawingElement).toList(); @@ -187,6 +241,15 @@ class DrawingProvider extends Notifier { return result; } + /// One drawing as the JSON saves and live sync write for it. + static Map elementToJson(DrawingElement element) => + _serializeDrawingElement(element); + + /// The drawing [elementToJson] wrote. A free drawing comes back without + /// its screen path; rebuild it before putting it on the canvas. + static DrawingElement elementFromJson(Map json) => + _deserializeDrawingElement(json); + static Map _serializeDrawingElement(DrawingElement element) { if (element is FreeDrawing) { return { @@ -1003,6 +1066,54 @@ int _readDrawingColorValue(Map json) { return 0xFFFFFFFF; } +/// [drawing] moved by [delta] in world units: every point and its bounding +/// box shift together, and a free drawing's screen path is rebuilt. +DrawingElement _translated( + DrawingElement drawing, + Offset delta, + CoordinateSystem coordinateSystem, +) { + final box = drawing.boundingBox; + final movedBox = box == null + ? null + : BoundingBox(min: box.min + delta, max: box.max + delta); + return switch (drawing) { + FreeDrawing() => drawing.copyWith( + listOfPoints: [for (final point in drawing.listOfPoints) point + delta], + boundingBox: movedBox, + cachedPolylineLengthUnits: drawing.cachedPolylineLengthUnits, + )..rebuildPath(coordinateSystem), + Line() => drawing.copyWith( + lineStart: drawing.lineStart + delta, + lineEnd: drawing.lineEnd + delta, + boundingBox: movedBox, + ), + RectangleDrawing() => RectangleDrawing( + start: drawing.start + delta, + end: drawing.end + delta, + colorValue: drawing.colorValue, + thickness: drawing.thickness, + boundingBox: movedBox, + isDotted: drawing.isDotted, + hasArrow: drawing.hasArrow, + id: drawing.id, + ), + EllipseDrawing() => EllipseDrawing( + start: drawing.start + delta, + end: drawing.end + delta, + colorValue: drawing.colorValue, + thickness: drawing.thickness, + boundingBox: movedBox, + isDotted: drawing.isDotted, + hasArrow: drawing.hasArrow, + id: drawing.id, + ), + _ => throw UnsupportedError( + 'Unsupported drawing element: ${drawing.runtimeType}', + ), + }; +} + BoundingBox _boundingBoxForPoints(Offset a, Offset b) { return BoundingBox( min: Offset(min(a.dx, b.dx), min(a.dy, b.dy)), diff --git a/lib/view_cone/cone_coverage.dart b/lib/view_cone/cone_coverage.dart index aea8babd..9a711252 100644 --- a/lib/view_cone/cone_coverage.dart +++ b/lib/view_cone/cone_coverage.dart @@ -33,6 +33,10 @@ class ConeCoverage extends ChangeNotifier { } void _changed() => notifyListeners(); + + /// Repaints the layer from where the cones sit now, after something above + /// them moved them without a new layout (a drag of the selection). + void moved() => notifyListeners(); } /// How much of the way a cone lifts the floor toward the theme's wall diff --git a/lib/widgets/canvas_selection_layer.dart b/lib/widgets/canvas_selection_layer.dart index 527b0530..d3d2689a 100644 --- a/lib/widgets/canvas_selection_layer.dart +++ b/lib/widgets/canvas_selection_layer.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -75,22 +76,32 @@ class _CanvasSelectionLayerState extends ConsumerState bool _boxAdds = false; /// The press that may become a click: where it went down, and whether a - /// box has started from it since. + /// box or a drag of the selection has started from it since. int? _clickPointer; Offset? _clickDown; - bool _clickBecameBox = false; + + /// The item the press landed on, before a drag of the selection holds the + /// rest of the selection under the same pointer. + Set _clickHeld = const {}; + bool _pressBecameDrag = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); HardwareKeyboard.instance.addHandler(_releaseSpace); + _groupDrag = ref.read(canvasGroupDragProvider.notifier); } + late final CanvasGroupDragNotifier _groupDrag; + @override void dispose() { HardwareKeyboard.instance.removeHandler(_releaseSpace); WidgetsBinding.instance.removeObserver(this); + // A drag the canvas leaves mid-way (the editor closed) is dropped, not + // left drawn where it had carried its items. + if (_dragPointer != null) scheduleMicrotask(() => _groupDrag.set(null)); _focus.dispose(); super.dispose(); } @@ -131,6 +142,12 @@ class _CanvasSelectionLayerState extends ConsumerState return KeyEventResult.handled; } if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (key == LogicalKeyboardKey.escape && _dragOrigin != null) { + // Escape drops the drag; everything stays where it was. + _dragDropped = true; + ref.read(canvasGroupDragProvider.notifier).set(null); + return KeyEventResult.handled; + } if (key == LogicalKeyboardKey.escape && _box != null) { // Escape drops the box being drawn; the selection stays as it was. _boxOrigin = null; @@ -191,8 +208,8 @@ class _CanvasSelectionLayerState extends ConsumerState 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; + if (!_pressBecameDrag) _teachOnce(); + _pressBecameDrag = true; setState(() => _box = Rect.fromPoints(origin, details.localPosition)); } @@ -289,6 +306,7 @@ class _CanvasSelectionLayerState extends ConsumerState if (mounted) _focus.requestFocus(); }); } + _pressBecameDrag = false; if (!widget.enabled || event.kind != PointerDeviceKind.mouse || event.buttons != kPrimaryMouseButton || @@ -298,14 +316,20 @@ class _CanvasSelectionLayerState extends ConsumerState } _clickPointer = event.pointer; _clickDown = event.position; - _clickBecameBox = false; + _clickHeld = held; } void _onPointerUp(PointerUpEvent event) { + // A press that never moved draws no box and drags nothing, even when a + // drag won it only because nothing else wanted it (it ends after this). + if (!_pressBecameDrag) { + _boxOrigin = null; + if (event.pointer == _dragPointer) _endDrag(); + } if (event.pointer != _clickPointer) return; _clickPointer = null; final down = _clickDown; - if (_clickBecameBox || down == null) return; + if (_pressBecameDrag || down == null) return; // A press that moved is a drag of something, not a click. if ((event.position - down).distance > kTouchSlop) return; _click(event); @@ -313,8 +337,7 @@ class _CanvasSelectionLayerState extends ConsumerState void _click(PointerUpEvent event) { final selection = ref.read(canvasSelectionProvider.notifier); - final hold = ref.read(editorPointersProvider)[event.pointer]; - final hit = _clickedItem(hold?.entityIds ?? const {}, event); + final hit = _clickedItem(_clickHeld, event); if (hit == null) { if (!_shiftHeld) selection.clear(); return; @@ -335,8 +358,14 @@ class _CanvasSelectionLayerState extends ConsumerState } return null; } - final onCanvas = canvas.globalToLocal(event.position); - final point = _stored(onCanvas); + return _drawingAt(event.position); + } + + /// The drawing on top under the screen point [global], if any. + SelectedItem? _drawingAt(Offset global) { + final canvas = _canvas; + if (canvas == null) return null; + final point = _stored(canvas.globalToLocal(global)); final zoom = ref.read(screenZoomProvider); final reach = CoordinateSystem.instance .screenToCoordinate(Offset(_clickReach / zoom, 0)) @@ -354,22 +383,150 @@ class _CanvasSelectionLayerState extends ConsumerState /// How far from a stroke a click still selects it, in screen pixels. static const double _clickReach = 6; + /// Whether a press takes hold of the selection to drag it: it lands on a + /// selected item that moves, and there is more than that item to move. A + /// lone selected item drags as it always has (Ctrl to copy it, onto an + /// agent to join it); a drawing has no drag of its own, so it is taken + /// alone too. The items it would carry are noted now, as they are. + bool _grabsSelection(PointerDownEvent event) { + if (!widget.enabled || ref.read(canvasPanKeyHeldProvider)) return false; + final selection = ref.read(canvasSelectionProvider); + final held = + ref.read(editorPointersProvider)[event.pointer]?.entityIds ?? const {}; + final pressed = held.isNotEmpty + ? held + : {if (_drawingAt(event.position) case final drawing?) drawing.id}; + final grabbed = [ + for (final item in selection) + if (movesWithSelection(item.kind) && pressed.contains(item.id)) item, + ]; + if (grabbed.isEmpty) return false; + if (selection.length == 1 && + grabbed.single.kind != SelectableKind.drawing) { + return false; + } + _dragPointer = event.pointer; + _dragIds = { + for (final item in selection) + if (movesWithSelection(item.kind)) item.id, + }; + return true; + } + + /// The drag of the selection: the pointer that pressed it, the items it + /// carries (fixed when it began, whatever the selection does meanwhile), + /// where it began, how far it may go each way so nothing leaves the map, + /// and whether Escape dropped it. + int? _dragPointer; + Set? _dragIds; + Offset? _dragOrigin; + Rect _dragRoom = Rect.zero; + bool _dragDropped = false; + + void _onDragStart(DragStartDetails details) { + final ids = _dragIds; + final pointer = _dragPointer; + if (ids == null || pointer == null) return; + _dragOrigin = details.localPosition; + _dragDropped = false; + _dragRoom = _roomToMove(ids); + // A teammate's change to what is being dragged waits for the drop. + final pointers = ref.read(editorPointersProvider.notifier); + for (final id in ids) { + pointers.holdEntity(pointer, id); + } + } + + void _onDragUpdate(DragUpdateDetails details) { + final origin = _dragOrigin; + final ids = _dragIds; + if (origin == null || ids == null || _dragDropped) return; + final moved = details.localPosition - origin; + _pressBecameDrag = true; + ref.read(canvasGroupDragProvider.notifier).set(( + ids: ids, + by: Offset( + moved.dx.clamp(_dragRoom.left, _dragRoom.right), + moved.dy.clamp(_dragRoom.top, _dragRoom.bottom), + ), + )); + } + + void _onDragEnd(DragEndDetails details) { + final drag = ref.read(canvasGroupDragProvider); + final dropped = _dragDropped; + _endDrag(); + if (drag == null || dropped) return; + // The drag moved what is drawn; items are saved attack side up. + final world = CoordinateSystem.instance.screenToCoordinate(drag.by); + ref + .read(canvasSelectionProvider.notifier) + .moveItems(drag.ids, _isAttack ? world : -world); + } + + void _endDrag() { + _dragPointer = null; + _dragIds = null; + _dragOrigin = null; + if (ref.read(canvasGroupDragProvider) != null) { + ref.read(canvasGroupDragProvider.notifier).set(null); + } + } + + /// How far the items [ids] can be dragged each way, in canvas pixels, + /// before the middle of one of them would leave the map. + Rect _roomToMove(Set ids) { + final canvas = _canvas; + if (canvas == null) return Rect.zero; + final points = [ + for (final (:item, :corners) in _registry.itemsIn(canvas)) + if (ids.contains(item.id)) _middle(corners), + for (final drawing in ref.read(drawingProvider).elements) + if (ids.contains(drawing.id)) + if (drawing.boundingBox case final bounds?) ...[ + _drawn(bounds.min), + _drawn(bounds.max), + ], + ]; + if (points.isEmpty) return Rect.zero; + final coordinates = CoordinateSystem.instance; + // Clear of the edge where a drop deletes (CoordinateSystem.isOutOfBounds). + final margin = coordinates.coordinateToScreen(const Offset(20, 0)).dx; + final size = coordinates.playAreaSize; + var spread = Rect.fromPoints(points.first, points.first); + for (final point in points.skip(1)) { + spread = spread.expandToInclude(Rect.fromPoints(point, point)); + } + final left = math.min(0.0, margin - spread.left); + final top = math.min(0.0, margin - spread.top); + return Rect.fromLTRB( + left, + top, + math.max(left, size.width - margin - spread.right), + math.max(top, size.height - margin - spread.bottom), + ); + } + /// 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, - }; + final ids = ref.watch(selectedDrawingIdsProvider); if (ids.isEmpty) return const []; + // They follow a drag of the selection, as the drawings do. + final drag = ref.watch(canvasGroupDragProvider); 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), + Rect.fromPoints(_drawn(bounds.min), _drawn(bounds.max)) + .inflate( + CoordinateSystem.instance.scale(drawing.thickness) / 2, + ) + .shift( + drag != null && drag.ids.contains(drawing.id) + ? drag.by + : Offset.zero, + ), ]; } @@ -391,15 +548,31 @@ class _CanvasSelectionLayerState extends ConsumerState child: RawGestureDetector( behavior: HitTestBehavior.opaque, gestures: { - if (selecting) - PanGestureRecognizer: GestureRecognizerFactoryWithHandlers< - PanGestureRecognizer>( - () => PanGestureRecognizer( - supportedDevices: const {PointerDeviceKind.mouse}, - allowedButtonsFilter: (buttons) => - buttons == kPrimaryMouseButton, - ), + // Kept while one is under way, so Space or a tool change + // can't drop it half done. + if (selecting || _dragPointer != null) + _SelectionDragRecognizer: + GestureRecognizerFactoryWithHandlers< + _SelectionDragRecognizer>( + _SelectionDragRecognizer.new, (recognizer) => recognizer + ..grabs = _grabsSelection + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _endDrag, + ), + if (selecting || _boxOrigin != null) + _BoxRecognizer: + GestureRecognizerFactoryWithHandlers<_BoxRecognizer>( + _BoxRecognizer.new, + (recognizer) => recognizer + // Not a press the selection's drag took, nor one with Space + // held (read at the press: no frame may have passed). + ..allows = ((event) => + event.pointer != _dragPointer && + !ref.read(canvasPanKeyHeldProvider)) ..dragStartBehavior = DragStartBehavior.down ..onStart = _onBoxStart ..onUpdate = _onBoxUpdate @@ -508,3 +681,44 @@ class _SelectionBoxPainter extends CustomPainter { bool shouldRepaint(_SelectionBoxPainter oldDelegate) => box != oldDelegate.box || zoom != oldDelegate.zoom; } + +/// Drags the selection from a press on one of its items. It competes like +/// any drag: an item's handles still win their own drags, and a tap still +/// reaches the item; the items' own drags stand aside (see itemDragLimit). +class _SelectionDragRecognizer extends PanGestureRecognizer { + // Every device whose drags the items would otherwise take: their own drag + // stands aside while they are selected together. + _SelectionDragRecognizer() + : super( + supportedDevices: const { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + }, + allowedButtonsFilter: (buttons) => buttons == kPrimaryMouseButton, + ); + + late bool Function(PointerDownEvent event) grabs; + + @override + bool isPointerAllowed(PointerEvent event) => + super.isPointerAllowed(event) && + (event is! PointerDownEvent || grabs(event)); +} + +/// Draws a selection box from a press the selection's drag did not take. +class _BoxRecognizer extends PanGestureRecognizer { + _BoxRecognizer() + : super( + supportedDevices: const {PointerDeviceKind.mouse}, + allowedButtonsFilter: (buttons) => buttons == kPrimaryMouseButton, + ); + + late bool Function(PointerDownEvent event) allows; + + @override + bool isPointerAllowed(PointerEvent event) => + super.isPointerAllowed(event) && + (event is! PointerDownEvent || allows(event)); +} diff --git a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart index 8e3e0094..37e38dea 100644 --- a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart +++ b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart @@ -184,6 +184,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { if (index < 0) { return Draggable( + maxSimultaneousDrags: itemDragLimit(context), dragAnchorStrategy: ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, data: widget.data, @@ -362,6 +363,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { }); }, child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: widget.data, dragAnchorStrategy: (draggable, context, position) { final RenderBox renderObject = @@ -470,6 +472,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { left: screenPosition.dx, top: screenPosition.dy, child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), dragAnchorStrategy: ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, data: widget.data, @@ -615,6 +618,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { }); }, child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: widget.data, dragAnchorStrategy: (draggable, context, position) { _visionDragOrigin.start( diff --git a/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart b/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart index 4f85caa9..8edebb60 100644 --- a/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart +++ b/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart @@ -177,6 +177,7 @@ class _PlacedDeadlockBarrierMeshWidgetState left: iconInset, top: iconInset, child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: widget.data, dragAnchorStrategy: (draggable, context, position) { final renderObject = context.findRenderObject()! as RenderBox; diff --git a/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart index 9b440556..2433f090 100644 --- a/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; @@ -233,6 +234,7 @@ class _PlacedCircleAgentWidgetState clipBehavior: Clip.none, children: [ Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: current, dragAnchorStrategy: ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, diff --git a/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart index af07c6c4..b5276940 100644 --- a/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -299,6 +300,7 @@ class _PlacedViewConeAgentWidgetState }); }, child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: current, dragAnchorStrategy: (draggable, context, position) { _dragOrigin.start( diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index b7bb50b6..73cffeb1 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -181,6 +181,7 @@ class _PlacedImageBuilderState extends State { }, onPanEnd: (_) => _finishResize(ref), child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: widget.placedImage, feedback: ZoomTransform( child: IgnorePointer( diff --git a/lib/widgets/draggable_widgets/placed_widget_builder.dart b/lib/widgets/draggable_widgets/placed_widget_builder.dart index f6d747c1..9a38b4f3 100644 --- a/lib/widgets/draggable_widgets/placed_widget_builder.dart +++ b/lib/widgets/draggable_widgets/placed_widget_builder.dart @@ -596,6 +596,11 @@ class _AgentListState extends ConsumerState<_AgentList> { isAttack: isAttack, ).dy, child: Draggable( + // One of several selected: a drag on it moves them all. + maxSimultaneousDrags: + ref.watch(dragsWithSelectionProvider(agent.id)) + ? 0 + : null, data: agent, dragAnchorStrategy: zoomDragAnchorStrategy, onDragStarted: () { diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index 7184351e..b1c4e3f5 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -188,6 +188,7 @@ class _PlacedTextBuilderState extends ConsumerState { }, onPanEnd: (_) => _finishResize(), child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: widget.placedText, feedback: Opacity( opacity: 0.8, diff --git a/lib/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart index 6d439f2d..fc143649 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart @@ -135,6 +135,7 @@ class _PlacedCustomCircleWidgetState clipBehavior: Clip.none, children: [ Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: utilityRef, dragAnchorStrategy: ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, diff --git a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart index 8d97c10f..6f628930 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart @@ -178,6 +178,7 @@ class _PlacedCustomRectangleWidgetState left: insetX, top: insetY, child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: utilityRef, dragAnchorStrategy: (draggable, context, position) => _rotatedDragAnchor( diff --git a/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart index 61527524..d748de85 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart @@ -211,6 +211,7 @@ class _PlacedViewConeWidgetState extends ConsumerState { }); }, child: Draggable( + maxSimultaneousDrags: itemDragLimit(context), data: widget.utility, dragAnchorStrategy: (draggable, context, position) { _dragOrigin.start( diff --git a/lib/widgets/draggable_widgets/utilities/utility_widget_builder.dart b/lib/widgets/draggable_widgets/utilities/utility_widget_builder.dart index a8ed6d0e..8c0fb5b8 100644 --- a/lib/widgets/draggable_widgets/utilities/utility_widget_builder.dart +++ b/lib/widgets/draggable_widgets/utilities/utility_widget_builder.dart @@ -40,6 +40,7 @@ class _UtilityWidgetBuilderState extends ConsumerState { final abilitySize = ref .watch(strategySettingsProvider.select((state) => state.abilitySize)); return Draggable( + maxSimultaneousDrags: itemDragLimit(context), dragAnchorStrategy: ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, data: widget.utility, diff --git a/lib/widgets/drawing_painter.dart b/lib/widgets/drawing_painter.dart index 490ae496..c799162b 100644 --- a/lib/widgets/drawing_painter.dart +++ b/lib/widgets/drawing_painter.dart @@ -8,6 +8,7 @@ import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/traversal_speed.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; @@ -65,11 +66,19 @@ class _InteractivePainterState extends ConsumerState { final bool isAttack = widget.isAttackOverride ?? ref.watch(mapProvider.select((state) => state.isAttack)); + // Selected drawings follow a drag of the selection. This layer is turned + // with the map on defense, so the drag's direction turns too. + final groupDrag = ref.watch(canvasGroupDragProvider); + final shiftedIds = groupDrag?.ids ?? const {}; CustomPainter drawingPainter = DrawingPainter( updateCounter: drawingState.updateCounter, coordinateSystem: coordinateSystem, elements: drawingState.elements, // Pass the data directly - currentLine: drawingState.currentElement); + currentLine: drawingState.currentElement, + shiftedIds: shiftedIds, + shift: isAttack + ? groupDrag?.by ?? Offset.zero + : -(groupDrag?.by ?? Offset.zero)); final currentInteractionState = ref.watch(interactionStateProvider); @@ -527,11 +536,18 @@ class DrawingPainter extends CustomPainter { final DrawingElement? currentLine; final int updateCounter; + /// Drawings drawn [shift] from where they are saved, in this painter's + /// coordinates, while a drag of the selection carries them. + final Set shiftedIds; + final Offset shift; + DrawingPainter({ required this.updateCounter, required this.currentLine, required this.coordinateSystem, required this.elements, + this.shiftedIds = const {}, + this.shift = Offset.zero, }); @override @@ -621,12 +637,12 @@ class DrawingPainter extends CustomPainter { } } - for (int i = 0; i < elements.length; i++) { - paint.color = elements[i].color; - if (elements[i] is Line) { - paintLine(canvas, paint, elements[i] as Line); - } else if (elements[i] is RectangleDrawing) { - final rectangle = elements[i] as RectangleDrawing; + void paintElement(DrawingElement element) { + paint.color = element.color; + if (element is Line) { + paintLine(canvas, paint, element); + } else if (element is RectangleDrawing) { + final rectangle = element; paint.strokeWidth = coordinateSystem.scale(rectangle.thickness); final screenRect = Rect.fromPoints( coordinateSystem.coordinateToScreen(rectangle.start), @@ -640,13 +656,13 @@ class DrawingPainter extends CustomPainter { } else { canvas.drawRect(screenRect, paint); } - } else if (elements[i] is EllipseDrawing) { - paintEllipse(canvas, paint, elements[i] as EllipseDrawing); - } else if (elements[i] is FreeDrawing) { - FreeDrawing freeDrawing = elements[i] as FreeDrawing; + } else if (element is EllipseDrawing) { + paintEllipse(canvas, paint, element); + } else if (element is FreeDrawing) { + final freeDrawing = element; paint.strokeWidth = coordinateSystem.scale(freeDrawing.thickness); List points = freeDrawing.listOfPoints; - if (points.length < 2) continue; + if (points.length < 2) return; if (freeDrawing.isDotted) { final space = coordinateSystem.scale(10); @@ -657,7 +673,7 @@ class DrawingPainter extends CustomPainter { } if (freeDrawing.hasArrow) { - if (points.length < 2) continue; + if (points.length < 2) return; final from = coordinateSystem.coordinateToScreen(points[points.length - 2]); @@ -667,6 +683,19 @@ class DrawingPainter extends CustomPainter { } } + for (final element in elements) { + if (!shiftedIds.contains(element.id)) paintElement(element); + } + if (shiftedIds.isNotEmpty) { + // Drawn where a drag of the selection has carried them. + canvas.save(); + canvas.translate(shift.dx, shift.dy); + for (final element in elements) { + if (shiftedIds.contains(element.id)) paintElement(element); + } + canvas.restore(); + } + if (currentLine != null) { paint.color = currentLine!.color; if (currentLine is FreeDrawing) { @@ -716,6 +745,8 @@ class DrawingPainter extends CustomPainter { @override bool shouldRepaint(DrawingPainter oldDelegate) { if (oldDelegate.updateCounter != updateCounter || + oldDelegate.shift != shift || + !identical(oldDelegate.shiftedIds, shiftedIds) || oldDelegate.coordinateSystem.isScreenshot) { // log("Repainting DrawingPainter"); return true; diff --git a/lib/widgets/editor_operation_scope.dart b/lib/widgets/editor_operation_scope.dart index 09fd71f2..40a511ed 100644 --- a/lib/widgets/editor_operation_scope.dart +++ b/lib/widgets/editor_operation_scope.dart @@ -128,21 +128,57 @@ class EditorEntityLayer extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { void hold(PointerEvent event) => ref.read(editorPointersProvider.notifier).holdEntity(event.pointer, id); + // While the selection is dragged, an item it carries is drawn where the + // drag has carried it; it moves for real when the drag ends. + final shift = ref.watch( + canvasGroupDragProvider.select( + (drag) => drag != null && drag.ids.contains(id) ? drag.by : null, + ), + ); return Positioned.fill( child: Listener( behavior: HitTestBehavior.deferToChild, onPointerDown: hold, onPointerPanZoomStart: hold, - child: ItemOutlineScope( - id: id, - kind: kind, - child: Stack(clipBehavior: Clip.none, children: [child]), + // Always here, so the item keeps its state when a drag starts. + child: Transform.translate( + offset: shift ?? Offset.zero, + child: _DragsWithSelection( + value: ref.watch(dragsWithSelectionProvider(id)), + child: ItemOutlineScope( + id: id, + kind: kind, + child: Stack(clipBehavior: Clip.none, children: [child]), + ), + ), ), ), ); } } +/// Whether the item below is one of several selected items that move +/// together (see [itemDragLimit]). +class _DragsWithSelection extends InheritedWidget { + const _DragsWithSelection({required this.value, required super.child}); + + final bool value; + + @override + bool updateShouldNotify(_DragsWithSelection oldWidget) => + value != oldWidget.value; +} + +/// The `maxSimultaneousDrags` of a canvas item's own [Draggable]: none while +/// the item is one of several selected, so a drag on it moves the whole +/// selection instead (the canvas's selection drag takes it). Its handles +/// and taps are left as they are. +int? itemDragLimit(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_DragsWithSelection>()?.value ?? + false + ? 0 + : null; + /// Draws the [EditorItemOutline]s under it in the color of the teammate /// 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 diff --git a/test/canvas_selection_layer_test.dart b/test/canvas_selection_layer_test.dart index b6dccb9a..50e3ce06 100644 --- a/test/canvas_selection_layer_test.dart +++ b/test/canvas_selection_layer_test.dart @@ -11,6 +11,7 @@ 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/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -35,18 +36,42 @@ class _NoopStrategyProvider extends StrategyProvider { void setUnsaved() {} } +/// How often the handle on item a has been dragged. +var _handleMoves = 0; + /// 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( +/// canvas draws one: in its own layer, marking what it draws. With +/// [handle], a 6 px handle in its top-right corner takes drags of its own, +/// as a resize or rotate handle does. +Widget _item(String id, Offset at, {bool handle = false}) => 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), + child: SizedBox.square( + dimension: 20, + child: Stack( + children: [ + const EditorItemOutline( + child: SizedBox.square( + dimension: 20, + child: ColoredBox(color: Colors.white), + ), + ), + if (handle) + Positioned( + right: 0, + top: 0, + child: GestureDetector( + onPanUpdate: (_) => _handleMoves++, + child: const SizedBox.square( + dimension: 6, + child: ColoredBox(color: Colors.black), + ), + ), + ), + ], ), ), ), @@ -108,7 +133,7 @@ void main() { height: 450, child: Stack( children: [ - _item('a', const Offset(100, 100)), + _item('a', const Offset(100, 100), handle: true), _item('b', const Offset(200, 100)), _item('c', const Offset(400, 300)), ], @@ -236,4 +261,185 @@ void main() { await tester.pump(); expect(container.read(canvasSelectionProvider), {_a, _b, _c}); }); + + Offset positionOf(String id) => container + .read(agentProvider) + .firstWhere((agent) => agent.id == id) + .position; + + Future dragFrom( + 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(); + } + return gesture; + } + + testWidgets('dragging a selected item moves the selection in one step', ( + tester, + ) async { + await pumpCanvas(tester); + container.read(canvasSelectionProvider.notifier).select([_a, _b]); + await tester.pump(); + + final gesture = await dragFrom( + tester, + const Offset(110, 110), + const Offset(160, 130), + ); + // Drawn where the drag has carried them, saved where they were. + expect(container.read(canvasGroupDragProvider)?.by, const Offset(50, 20)); + expect(positionOf('a'), Offset.zero); + await gesture.up(); + await tester.pump(); + + final moved = CoordinateSystem.instance.screenToCoordinate( + const Offset(50, 20), + ); + expect(positionOf('a'), moved); + expect(positionOf('b'), moved); + expect(positionOf('c'), Offset.zero); + expect(container.read(canvasGroupDragProvider), isNull); + expect(container.read(canvasSelectionProvider), {_a, _b}); + + container.read(actionProvider.notifier).undoAction(); + expect(positionOf('a'), Offset.zero); + expect(positionOf('b'), Offset.zero); + }); + + testWidgets('Escape during a drag leaves everything where it was', ( + tester, + ) async { + await pumpCanvas(tester); + container.read(canvasSelectionProvider.notifier).select([_a, _b]); + await tester.pump(); + + final gesture = await dragFrom( + tester, + const Offset(110, 110), + const Offset(160, 130), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await gesture.up(); + await tester.pump(); + + expect(positionOf('a'), Offset.zero); + expect(positionOf('b'), Offset.zero); + expect(container.read(canvasGroupDragProvider), isNull); + expect(container.read(canvasSelectionProvider), {_a, _b}); + }); + + testWidgets('a drag stops where the selection would leave the map', ( + tester, + ) async { + await pumpCanvas(tester); + container.read(canvasSelectionProvider.notifier).select([_a, _c]); + await tester.pump(); + + final gesture = await dragFrom( + tester, + const Offset(410, 310), + const Offset(410, 900), + ); + await gesture.up(); + await tester.pump(); + + // c's middle (410, 310) stops short of the bottom edge; a moves as far. + final margin = + CoordinateSystem.instance.coordinateToScreen(const Offset(20, 0)).dx; + final moved = CoordinateSystem.instance.screenToCoordinate( + Offset(0, 450 - margin - 310), + ); + expect(positionOf('c').dy, closeTo(moved.dy, 0.001)); + expect(positionOf('a'), positionOf('c')); + }); + + testWidgets('selecting more mid-drag moves only what the drag began with', ( + tester, + ) async { + await pumpCanvas(tester); + container.read(canvasSelectionProvider.notifier).select([_a, _b]); + await tester.pump(); + + final gesture = await dragFrom( + tester, + const Offset(110, 110), + const Offset(110, 300), + ); + container.read(canvasSelectionProvider.notifier).selectAll(); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(positionOf('a'), isNot(Offset.zero)); + expect(positionOf('c'), Offset.zero); + }); + + testWidgets('a click on one of the selection selects that one', ( + tester, + ) async { + await pumpCanvas(tester); + container.read(canvasSelectionProvider.notifier).select([_a, _b]); + await tester.pump(); + + await tester.tapAt(const Offset(210, 110), kind: PointerDeviceKind.mouse); + await tester.pump(); + expect(container.read(canvasSelectionProvider), {_b}); + + container.read(canvasSelectionProvider.notifier).select([_a, _b]); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.tapAt(const Offset(210, 110), kind: PointerDeviceKind.mouse); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(container.read(canvasSelectionProvider), {_a}); + }); + + testWidgets("a selected item's handle keeps its own drag", (tester) async { + await pumpCanvas(tester); + container.read(canvasSelectionProvider.notifier).select([_a, _b]); + await tester.pump(); + _handleMoves = 0; + + final gesture = await dragFrom( + tester, + const Offset(117, 103), + const Offset(160, 103), + ); + await gesture.up(); + await tester.pump(); + + expect(_handleMoves, greaterThan(0)); + expect(positionOf('a'), Offset.zero); + expect(positionOf('b'), Offset.zero); + }); + + testWidgets('Space pressed mid-drag still ends the drag', (tester) async { + await pumpCanvas(tester); + container.read(canvasSelectionProvider.notifier).select([_a, _b]); + await tester.pump(); + + final gesture = await dragFrom( + tester, + const Offset(110, 110), + const Offset(160, 130), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.pump(); + await gesture.up(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + + expect(container.read(canvasGroupDragProvider), isNull); + expect(positionOf('a'), positionOf('b')); + expect(positionOf('a'), isNot(Offset.zero)); + }); } diff --git a/test/canvas_selection_test.dart b/test/canvas_selection_test.dart index 9c0b6058..1b446376 100644 --- a/test/canvas_selection_test.dart +++ b/test/canvas_selection_test.dart @@ -143,4 +143,32 @@ void main() { (kind: SelectableKind.drawing, id: 'd'), }); }); + + test('moving a selection moves agents and drawings, and undoes in one step', + () { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + _agent('a', const Offset(100, 100)), + _agent('b', const Offset(300, 300)), + ]); + container.read(drawingProvider.notifier).fromHive([_line('d')]); + final selection = container.read(canvasSelectionProvider.notifier); + + selection.select([_a, (kind: SelectableKind.drawing, id: 'd')]); + selection.moveItems({'a', 'd'}, const Offset(50, 20)); + + Offset agentAt(String id) => container + .read(agentProvider) + .firstWhere((agent) => agent.id == id) + .position; + Line line() => container.read(drawingProvider).elements.single as Line; + expect(agentAt('a'), const Offset(150, 120)); + expect(agentAt('b'), const Offset(300, 300)); + expect(line().lineStart, const Offset(150, 120)); + + container.read(actionProvider.notifier).undoAction(); + + expect(agentAt('a'), const Offset(100, 100)); + expect(line().lineStart, const Offset(100, 100)); + }); } diff --git a/test/drawing_translate_test.dart b/test/drawing_translate_test.dart new file mode 100644 index 00000000..957beb20 --- /dev/null +++ b/test/drawing_translate_test.dart @@ -0,0 +1,414 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/field_merge.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/bounding_box.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_history_models.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _NoopStrategyProvider extends StrategyProvider { + @override + StrategyState build() { + return const StrategyState( + strategyId: 'drawing-translate-test', + 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); + return container; +} + +const _delta = Offset(15, -5); + +Line _line() => Line( + id: 'line', + lineStart: const Offset(100, 100), + lineEnd: const Offset(200, 160), + color: Colors.red, + boundingBox: BoundingBox( + min: const Offset(100, 100), + max: const Offset(200, 160), + ), + isDotted: false, + hasArrow: true, + ); + +RectangleDrawing _rectangle() => RectangleDrawing( + id: 'rectangle', + start: const Offset(400, 300), + end: const Offset(300, 250), + color: Colors.blue, + boundingBox: BoundingBox( + min: const Offset(300, 250), + max: const Offset(400, 300), + ), + isDotted: true, + hasArrow: false, + ); + +EllipseDrawing _ellipse() => EllipseDrawing( + id: 'ellipse', + start: const Offset(500, 500), + end: const Offset(560, 540), + color: Colors.green, + boundingBox: BoundingBox( + min: const Offset(500, 500), + max: const Offset(560, 540), + ), + isDotted: false, + hasArrow: false, + ); + +FreeDrawing _free({List? points}) { + final listOfPoints = + points ?? const [Offset(600, 600), Offset(640, 620), Offset(700, 610)]; + return FreeDrawing( + id: 'free', + listOfPoints: [...listOfPoints], + color: Colors.white, + boundingBox: BoundingBox( + min: const Offset(600, 600), + max: const Offset(700, 620), + ), + isDotted: false, + hasArrow: false, + ); +} + +Line _untouched() => Line( + id: 'untouched', + lineStart: const Offset(50, 50), + lineEnd: const Offset(60, 60), + color: Colors.yellow, + boundingBox: BoundingBox( + min: const Offset(50, 50), + max: const Offset(60, 60), + ), + isDotted: false, + hasArrow: false, + ); + +List _page() => + [_line(), _rectangle(), _ellipse(), _free(), _untouched()]; + +const _moving = {'line', 'rectangle', 'ellipse', 'free'}; + +/// Every drawing as saves and live sync write it. +String _saved(ProviderContainer container) => + DrawingProvider.objectToJson(container.read(drawingProvider).elements); + +DrawingElement _drawing(ProviderContainer container, String id) => + container.read(drawingProvider).elements.singleWhere((d) => d.id == id); + +/// The drawings as a teammate's change brings them back from the server. +void _mergeFromServer( + ProviderContainer container, + List incoming, +) { + container.read(drawingProvider.notifier).mergeRemote( + [for (final drawing in incoming) cloneDrawingElement(drawing)], + (_) => false); +} + +/// The cloud payload live sync builds for [drawing]. +CloudPayload _cloudPayload(DrawingElement drawing) { + final encoded = jsonDecode(DrawingProvider.objectToJson([drawing])) as List; + return cloudElementPayload( + kind: 'drawing', + data: Map.from(encoded.single as Map) + ..putIfAbsent('elementType', () => 'drawing'), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); + + test('moves every point of each drawing kind, keeping the stack order', () { + final container = _container(); + final drawings = container.read(drawingProvider.notifier) + ..fromHive(_page()); + + drawings.translateDrawings(_moving, _delta); + + final elements = container.read(drawingProvider).elements; + expect(elements.map((d) => d.id), + ['line', 'rectangle', 'ellipse', 'free', 'untouched']); + + final line = elements[0] as Line; + expect(line.lineStart, const Offset(115, 95)); + expect(line.lineEnd, const Offset(215, 155)); + expect(line.boundingBox!.min, const Offset(115, 95)); + expect(line.boundingBox!.max, const Offset(215, 155)); + expect(line.colorValue, Colors.red.toARGB32()); + expect(line.hasArrow, isTrue); + + final rectangle = elements[1] as RectangleDrawing; + expect(rectangle.start, const Offset(415, 295)); + expect(rectangle.end, const Offset(315, 245)); + expect(rectangle.boundingBox!.min, const Offset(315, 245)); + expect(rectangle.boundingBox!.max, const Offset(415, 295)); + expect(rectangle.isDotted, isTrue); + + final ellipse = elements[2] as EllipseDrawing; + expect(ellipse.start, const Offset(515, 495)); + expect(ellipse.end, const Offset(575, 535)); + expect(ellipse.boundingBox!.min, const Offset(515, 495)); + expect(ellipse.boundingBox!.max, const Offset(575, 535)); + + final free = elements[3] as FreeDrawing; + const movedPoints = [ + Offset(615, 595), + Offset(655, 615), + Offset(715, 605), + ]; + expect(free.listOfPoints, movedPoints); + expect(free.boundingBox!.min, const Offset(615, 595)); + expect(free.boundingBox!.max, const Offset(715, 615)); + expect(free.cachedPolylineLengthUnits, + closeTo(_free().cachedPolylineLengthUnits, 1e-9)); + // The path on screen is the one a stroke drawn there would get. + final drawnThere = _free(points: movedPoints) + ..rebuildPath(CoordinateSystem.instance); + expect(free.path.getBounds(), drawnThere.path.getBounds()); + + expect(elements[4].boundingBox!.min, const Offset(50, 50)); + expect(identical(elements[4], _drawing(container, 'untouched')), isTrue); + }); + + test('the eraser finds a moved drawing where it now is', () { + final container = _container(); + final drawings = container.read(drawingProvider.notifier) + ..fromHive([_line()]); + + drawings.translateDrawings({'line'}, const Offset(0, 200)); + + final line = _drawing(container, 'line'); + expect(isNearDrawing(line, const Offset(150, 330), 2), isTrue); + expect(isNearDrawing(line, const Offset(150, 130), 2), isFalse); + }); + + test('undo puts back the exact geometry and redo moves it again', () { + final container = _container(); + final drawings = container.read(drawingProvider.notifier) + ..fromHive(_page()); + final history = container.read(actionProvider.notifier); + final before = _saved(container); + + drawings.translateDrawings(_moving, const Offset(0.1, 0.2)); + final after = _saved(container); + expect(after, isNot(before)); + + for (var step = 0; step < _moving.length; step++) { + history.undoAction(); + } + expect(_saved(container), before); + expect(container.read(actionProvider), isEmpty); + final free = _drawing(container, 'free') as FreeDrawing; + expect( + free.path.getBounds(), + (_free()..rebuildPath(CoordinateSystem.instance)).path.getBounds(), + ); + + for (var step = 0; step < _moving.length; step++) { + history.redoAction(); + } + expect(_saved(container), after); + }); + + test('moved with an agent in one transaction, both undo as one step', () { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + PlacedAgent( + id: 'agent', + type: AgentType.jett, + position: const Offset(300, 300), + ), + ]); + final drawings = container.read(drawingProvider.notifier) + ..fromHive(_page()); + final history = container.read(actionProvider.notifier); + final drawingsBefore = _saved(container); + + history.performTransaction( + groups: const [ActionGroup.agent, ActionGroup.drawing], + mutation: () { + container + .read(agentProvider.notifier) + .updatePosition(const Offset(315, 295), 'agent'); + drawings.translateDrawings(_moving, _delta); + }, + ); + final drawingsAfter = _saved(container); + expect(container.read(actionProvider), hasLength(1)); + + history.undoAction(); + expect( + container.read(agentProvider).single.position, const Offset(300, 300)); + expect(_saved(container), drawingsBefore); + expect(container.read(actionProvider), isEmpty); + + history.redoAction(); + expect( + container.read(agentProvider).single.position, const Offset(315, 295)); + expect(_saved(container), drawingsAfter); + }); + + group('after a teammate changed the page', () { + test('undo leaves a drawing they deleted deleted', () { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + PlacedAgent( + id: 'agent', + type: AgentType.jett, + position: const Offset(300, 300), + ), + ]); + final drawings = container.read(drawingProvider.notifier) + ..fromHive([_line(), _untouched()]); + final history = container.read(actionProvider.notifier); + + history.performTransaction( + groups: const [ActionGroup.agent, ActionGroup.drawing], + mutation: () { + container + .read(agentProvider.notifier) + .updatePosition(const Offset(315, 295), 'agent'); + drawings.translateDrawings({'line'}, _delta); + }, + ); + _mergeFromServer(container, [_untouched()]); + + history.undoAction(); + expect(container.read(drawingProvider).elements.map((d) => d.id), + ['untouched']); + expect(container.read(agentProvider).single.position, + const Offset(300, 300)); + + history.redoAction(); + expect(container.read(drawingProvider).elements.map((d) => d.id), + ['untouched']); + expect(container.read(agentProvider).single.position, + const Offset(315, 295)); + }); + + test('a move of a drawing they deleted leaves history when reconciled', () { + final container = _container(); + final drawings = container.read(drawingProvider.notifier) + ..fromHive([_line(), _untouched()]); + final history = container.read(actionProvider.notifier); + + drawings.translateDrawings({'line'}, _delta); + drawings.translateDrawings({'untouched'}, _delta); + _mergeFromServer(container, [_drawing(container, 'untouched')]); + history.reconcileHistory(); + + final steps = container.read(actionProvider); + expect(steps.map((step) => step.id), ['untouched']); + }); + + test('undo keeps the colour they gave the drawing', () { + final container = _container(); + final drawings = container.read(drawingProvider.notifier) + ..fromHive([_line()]); + final history = container.read(actionProvider.notifier); + + drawings.translateDrawings({'line'}, _delta); + final moved = _drawing(container, 'line') as Line; + _mergeFromServer(container, [moved.copyWith(color: Colors.purple)]); + + history.undoAction(); + final line = _drawing(container, 'line') as Line; + expect(line.lineStart, const Offset(100, 100)); + expect(line.lineEnd, const Offset(200, 160)); + expect(line.boundingBox!.min, const Offset(100, 100)); + expect(line.colorValue, Colors.purple.toARGB32()); + }); + + test('undo of a move they already put back changes nothing', () { + final container = _container(); + final drawings = container.read(drawingProvider.notifier) + ..fromHive([_free()]); + final history = container.read(actionProvider.notifier); + + drawings.translateDrawings({'free'}, _delta); + _mergeFromServer(container, [_free()]); + final putBack = _saved(container); + + history.undoAction(); + expect(_saved(container), putBack); + expect(container.read(actionProvider), isEmpty); + expect(history.poppedItems, isEmpty); + }); + }); + + test('a move changes only the geometry live sync merges together', () { + final cases = >{ + _line(): ['boundingBox', 'lineEnd', 'lineStart'], + _rectangle(): ['boundingBox', 'end', 'start'], + _ellipse(): ['boundingBox', 'end', 'start'], + _free(): ['boundingBox', 'listOfPoints'], + }; + for (final MapEntry(key: drawing, value: fields) in cases.entries) { + final container = _container(); + final drawings = container.read(drawingProvider.notifier) + ..fromHive([drawing]); + final before = _cloudPayload(_drawing(container, drawing.id)); + + drawings.translateDrawings({drawing.id}, _delta); + final after = _cloudPayload(_drawing(container, drawing.id)); + + expect(elementMergeFields(before, after), fields, + reason: drawing.runtimeType.toString()); + } + }); + + test('a zero move or an unknown id records nothing', () { + final container = _container(); + final drawings = container.read(drawingProvider.notifier) + ..fromHive([_line()]); + final elements = container.read(drawingProvider).elements; + + drawings.translateDrawings({'line'}, Offset.zero); + drawings.translateDrawings({'missing'}, _delta); + + expect(container.read(actionProvider), isEmpty); + expect( + identical(container.read(drawingProvider).elements, elements), isTrue); + }); + + test('a drawing edit round-trips through its history JSON', () { + for (final drawing in _page()) { + final state = ActionObjectState.drawing(drawing); + final restored = ActionObjectState.fromJson( + ActionObjectKind.drawing, + state.toJson(), + ); + expect(restored.toJson(), state.toJson()); + expect(restored.drawing.runtimeType, drawing.runtimeType); + } + }); +}