From cc0927ee207b802dca948397ab0e9957de25499c Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 27 Oct 2026 17:55:44 -0400 Subject: [PATCH 1/3] Let drawings move by a world delta, with an undo step A group move on the canvas needs to shift drawings, which could only be added and removed. translateDrawings moves every point and bounding box of the named drawings in place in the stack and records one edit per drawing, so a move inside performTransaction undoes as one step with the widgets moved beside it. Drawing edits replay through the same field-by-field path as other kinds: a drawing now has history JSON (the same fields saves and live sync write), so undo skips a drawing a teammate deleted, keeps a colour they changed, and counts a move they already put back as no change. --- lib/providers/action_history_models.dart | 11 +- lib/providers/drawing_provider.dart | 111 ++++++ test/drawing_translate_test.dart | 414 +++++++++++++++++++++++ 3 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 test/drawing_translate_test.dart diff --git a/lib/providers/action_history_models.dart b/lib/providers/action_history_models.dart index d340a304a..f8fd04177 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/drawing_provider.dart b/lib/providers/drawing_provider.dart index ed5f54954..8728aedf9 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/test/drawing_translate_test.dart b/test/drawing_translate_test.dart new file mode 100644 index 000000000..957beb20c --- /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); + } + }); +} From a37dcba51be18e1b47aed8bff32c70f16d9c5bc1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 27 Oct 2026 18:56:32 -0400 Subject: [PATCH 2/3] Drag a selection to move it all at once Pressing a selected item drags the whole selection: everything is drawn where the drag carries it (drawings and view cones too) and moves for real, as one undo step, on release. The drag stops before anything would leave the map, Escape drops it, and lineups stay where they are. A lone selected drawing can now be dragged too. --- lib/interactive_map.dart | 3 + lib/providers/canvas_selection_provider.dart | 87 ++++++++ lib/view_cone/cone_coverage.dart | 4 + lib/widgets/canvas_selection_layer.dart | 220 +++++++++++++++++-- lib/widgets/drawing_painter.dart | 58 +++-- lib/widgets/editor_operation_scope.dart | 17 +- test/canvas_selection_layer_test.dart | 102 +++++++++ test/canvas_selection_test.dart | 28 +++ 8 files changed, 486 insertions(+), 33 deletions(-) diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index 19ed0745b..3818146ca 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/canvas_selection_provider.dart b/lib/providers/canvas_selection_provider.dart index dfd8d1929..caecd9da7 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,33 @@ 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, + }; +}); + +/// How far a drag of the selection has carried it so far, in canvas pixels, +/// or null when none is under way. The selected 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 + Offset? build() => null; + + void set(Offset? offset) => state = offset; +} + +/// 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 +152,64 @@ class CanvasSelectionNotifier extends Notifier> { if (state.isNotEmpty) state = const {}; } + /// Moves everything selected that moves (see [movesWithSelection]) by + /// [by], in world coordinates, as one undo step. + void moveSelected(Offset by) { + final ids = { + for (final item in state) + if (movesWithSelection(item.kind)) item.id, + }; + 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/view_cone/cone_coverage.dart b/lib/view_cone/cone_coverage.dart index aea8babd7..9a711252d 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 527b0530c..57332268b 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,10 +76,10 @@ 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; + bool _pressBecameDrag = false; @override void initState() { @@ -131,6 +132,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 +198,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)); } @@ -298,14 +305,14 @@ class _CanvasSelectionLayerState extends ConsumerState } _clickPointer = event.pointer; _clickDown = event.position; - _clickBecameBox = false; + _pressBecameDrag = false; } void _onPointerUp(PointerUpEvent event) { 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); @@ -335,8 +342,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 +367,132 @@ 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. + 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; + return selection.length > 1 || + grabbed.single.kind == SelectableKind.drawing; + } + + /// Where the selection's drag began, how far it may go each way so + /// nothing leaves the map, and whether Escape dropped it. + Offset? _dragOrigin; + Rect _dragRoom = Rect.zero; + bool _dragDropped = false; + + void _onDragStart(PointerDownEvent event) { + _dragOrigin = event.localPosition; + _dragDropped = false; + _dragRoom = _roomToMove(); + // A teammate's change to what is being dragged waits for the drop. + final pointers = ref.read(editorPointersProvider.notifier); + for (final item in ref.read(canvasSelectionProvider)) { + if (movesWithSelection(item.kind)) { + pointers.holdEntity(event.pointer, item.id); + } + } + } + + void _onDragUpdate(Offset local) { + final origin = _dragOrigin; + if (origin == null || _dragDropped) return; + final moved = local - origin; + if (moved.distance > kTouchSlop) _pressBecameDrag = true; + if (!_pressBecameDrag) return; + ref.read(canvasGroupDragProvider.notifier).set( + Offset( + moved.dx.clamp(_dragRoom.left, _dragRoom.right), + moved.dy.clamp(_dragRoom.top, _dragRoom.bottom), + ), + ); + } + + void _onDragEnd() { + final shift = ref.read(canvasGroupDragProvider); + _dragOrigin = null; + ref.read(canvasGroupDragProvider.notifier).set(null); + if (shift == null || _dragDropped) return; + // The drag moved what is drawn; items are saved attack side up. + final world = CoordinateSystem.instance.screenToCoordinate(shift); + ref + .read(canvasSelectionProvider.notifier) + .moveSelected(_isAttack ? world : -world); + } + + void _onDragCancel() { + _dragOrigin = null; + ref.read(canvasGroupDragProvider.notifier).set(null); + } + + /// How far the selection can be dragged each way, in canvas pixels, before + /// the middle of something in it would leave the map. + Rect _roomToMove() { + final canvas = _canvas; + if (canvas == null) return Rect.zero; + final ids = { + for (final item in ref.read(canvasSelectionProvider)) + if (movesWithSelection(item.kind)) item.id, + }; + 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 shift = ref.watch(canvasGroupDragProvider) ?? Offset.zero; 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(shift), ]; } @@ -391,6 +514,18 @@ class _CanvasSelectionLayerState extends ConsumerState child: RawGestureDetector( behavior: HitTestBehavior.opaque, gestures: { + if (selecting) + _SelectionDragRecognizer: + GestureRecognizerFactoryWithHandlers< + _SelectionDragRecognizer>( + _SelectionDragRecognizer.new, + (recognizer) => recognizer + ..grabs = _grabsSelection + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _onDragCancel, + ), if (selecting) PanGestureRecognizer: GestureRecognizerFactoryWithHandlers< PanGestureRecognizer>( @@ -508,3 +643,56 @@ class _SelectionBoxPainter extends CustomPainter { bool shouldRepaint(_SelectionBoxPainter oldDelegate) => box != oldDelegate.box || zoom != oldDelegate.zoom; } + +/// Takes a press on the selection away from the item under it the moment it +/// lands, so the whole selection is dragged rather than that one item. +class _SelectionDragRecognizer extends OneSequenceGestureRecognizer { + _SelectionDragRecognizer() + : super( + supportedDevices: const {PointerDeviceKind.mouse}, + allowedButtonsFilter: (buttons) => buttons == kPrimaryMouseButton, + ); + + late bool Function(PointerDownEvent event) grabs; + late void Function(PointerDownEvent event) onStart; + late void Function(Offset local) onUpdate; + late VoidCallback onEnd; + late VoidCallback onCancel; + + @override + bool isPointerAllowed(PointerDownEvent event) => + super.isPointerAllowed(event) && grabs(event); + + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + // Won before the item's own drag can start. + resolve(GestureDisposition.accepted); + onStart(event); + } + + @override + void handleEvent(PointerEvent event) { + if (event is PointerMoveEvent) { + onUpdate(event.localPosition); + } else if (event is PointerUpEvent) { + onEnd(); + stopTrackingPointer(event.pointer); + } else if (event is PointerCancelEvent) { + onCancel(); + stopTrackingPointer(event.pointer); + } + } + + @override + void rejectGesture(int pointer) { + onCancel(); + super.rejectGesture(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) {} + + @override + String get debugDescription => 'selection drag'; +} diff --git a/lib/widgets/drawing_painter.dart b/lib/widgets/drawing_painter.dart index 490ae496b..9af46ae0f 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,20 @@ 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 == null + ? const {} + : ref.watch(selectedDrawingIdsProvider); 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 ?? Offset.zero : -(groupDrag ?? Offset.zero)); final currentInteractionState = ref.watch(interactionStateProvider); @@ -527,11 +537,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 +638,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 +657,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 +674,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 +684,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 +746,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 09fd71f2e..d51351365 100644 --- a/lib/widgets/editor_operation_scope.dart +++ b/lib/widgets/editor_operation_scope.dart @@ -128,15 +128,24 @@ 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, a selected item is drawn where the + // drag has carried it; it moves for real when the drag ends. + final dragged = movesWithSelection(kind) && + ref.watch(selectedCanvasIdsProvider.select((ids) => ids.contains(id))); + final shift = dragged ? ref.watch(canvasGroupDragProvider) : 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: ItemOutlineScope( + id: id, + kind: kind, + child: Stack(clipBehavior: Clip.none, children: [child]), + ), ), ), ); diff --git a/test/canvas_selection_layer_test.dart b/test/canvas_selection_layer_test.dart index b6dccb9ad..7bbc0b845 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'; @@ -236,4 +237,105 @@ 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), 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')); + }); } diff --git a/test/canvas_selection_test.dart b/test/canvas_selection_test.dart index 9c0b60588..1d4b2064c 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.moveSelected(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)); + }); } From 043a666446123dfe79d678a0f945849e8a959e11 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 27 Oct 2026 19:34:18 -0400 Subject: [PATCH 3/3] Let handles and clicks win over a drag of the selection Review found the selection's drag took every press on a selected item, so resize and rotate handles and a text item's click stopped working, a click could land on the wrong item, a selection grown mid-drag moved unclamped items off the map (agents dropped there are deleted), and Space mid-drag left items drawn where they were never moved. The selection's drag now competes like any drag: an item's own drag stands aside while it is one of several selected (itemDragLimit), handles and taps keep theirs. A drag carries the items it began with, a click goes to what the press landed on, and a drag under way keeps its recognizer until it ends. --- lib/providers/canvas_selection_provider.dart | 36 +-- lib/widgets/canvas_selection_layer.dart | 212 ++++++++++-------- .../ability/placed_ability_widget.dart | 4 + .../placed_deadlock_barrier_mesh_widget.dart | 1 + .../agents/placed_circle_agent_widget.dart | 2 + .../agents/placed_view_cone_agent_widget.dart | 2 + .../image/placed_image_builder.dart | 1 + .../placed_widget_builder.dart | 5 + .../text/placed_text_builder.dart | 1 + .../placed_custom_circle_widget.dart | 1 + .../placed_custom_rectangle_widget.dart | 1 + .../utilities/placed_view_cone_widget.dart | 1 + .../utilities/utility_widget_builder.dart | 1 + lib/widgets/drawing_painter.dart | 9 +- lib/widgets/editor_operation_scope.dart | 43 +++- test/canvas_selection_layer_test.dart | 120 +++++++++- test/canvas_selection_test.dart | 2 +- 17 files changed, 313 insertions(+), 129 deletions(-) diff --git a/lib/providers/canvas_selection_provider.dart b/lib/providers/canvas_selection_provider.dart index caecd9da7..a9c0d6b57 100644 --- a/lib/providers/canvas_selection_provider.dart +++ b/lib/providers/canvas_selection_provider.dart @@ -58,21 +58,32 @@ final selectedDrawingIdsProvider = Provider>((ref) { }; }); -/// How far a drag of the selection has carried it so far, in canvas pixels, -/// or null when none is under way. The selected items are drawn this far -/// from where they are saved until the drag ends and moves them. +/// 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( + NotifierProvider( CanvasGroupDragNotifier.new, ); -class CanvasGroupDragNotifier extends Notifier { +class CanvasGroupDragNotifier extends Notifier { @override - Offset? build() => null; + CanvasGroupDrag? build() => null; - void set(Offset? offset) => state = offset; + 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; @@ -152,13 +163,10 @@ class CanvasSelectionNotifier extends Notifier> { if (state.isNotEmpty) state = const {}; } - /// Moves everything selected that moves (see [movesWithSelection]) by - /// [by], in world coordinates, as one undo step. - void moveSelected(Offset by) { - final ids = { - for (final item in state) - if (movesWithSelection(item.kind)) item.id, - }; + /// 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) { diff --git a/lib/widgets/canvas_selection_layer.dart b/lib/widgets/canvas_selection_layer.dart index 57332268b..d3d2689a9 100644 --- a/lib/widgets/canvas_selection_layer.dart +++ b/lib/widgets/canvas_selection_layer.dart @@ -79,6 +79,10 @@ class _CanvasSelectionLayerState extends ConsumerState /// box or a drag of the selection has started from it since. int? _clickPointer; Offset? _clickDown; + + /// 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 @@ -86,12 +90,18 @@ class _CanvasSelectionLayerState extends ConsumerState 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(); } @@ -296,6 +306,7 @@ class _CanvasSelectionLayerState extends ConsumerState if (mounted) _focus.requestFocus(); }); } + _pressBecameDrag = false; if (!widget.enabled || event.kind != PointerDeviceKind.mouse || event.buttons != kPrimaryMouseButton || @@ -305,10 +316,16 @@ class _CanvasSelectionLayerState extends ConsumerState } _clickPointer = event.pointer; _clickDown = event.position; - _pressBecameDrag = 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; @@ -320,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; @@ -371,7 +387,7 @@ class _CanvasSelectionLayerState extends ConsumerState /// 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. + /// 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); @@ -385,69 +401,83 @@ class _CanvasSelectionLayerState extends ConsumerState if (movesWithSelection(item.kind) && pressed.contains(item.id)) item, ]; if (grabbed.isEmpty) return false; - return selection.length > 1 || - grabbed.single.kind == SelectableKind.drawing; + 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; } - /// Where the selection's drag began, how far it may go each way so - /// nothing leaves the map, and whether Escape dropped it. + /// 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(PointerDownEvent event) { - _dragOrigin = event.localPosition; + void _onDragStart(DragStartDetails details) { + final ids = _dragIds; + final pointer = _dragPointer; + if (ids == null || pointer == null) return; + _dragOrigin = details.localPosition; _dragDropped = false; - _dragRoom = _roomToMove(); + _dragRoom = _roomToMove(ids); // A teammate's change to what is being dragged waits for the drop. final pointers = ref.read(editorPointersProvider.notifier); - for (final item in ref.read(canvasSelectionProvider)) { - if (movesWithSelection(item.kind)) { - pointers.holdEntity(event.pointer, item.id); - } + for (final id in ids) { + pointers.holdEntity(pointer, id); } } - void _onDragUpdate(Offset local) { + void _onDragUpdate(DragUpdateDetails details) { final origin = _dragOrigin; - if (origin == null || _dragDropped) return; - final moved = local - origin; - if (moved.distance > kTouchSlop) _pressBecameDrag = true; - if (!_pressBecameDrag) return; - ref.read(canvasGroupDragProvider.notifier).set( - Offset( - moved.dx.clamp(_dragRoom.left, _dragRoom.right), - moved.dy.clamp(_dragRoom.top, _dragRoom.bottom), - ), - ); + 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() { - final shift = ref.read(canvasGroupDragProvider); - _dragOrigin = null; - ref.read(canvasGroupDragProvider.notifier).set(null); - if (shift == null || _dragDropped) return; + 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(shift); + final world = CoordinateSystem.instance.screenToCoordinate(drag.by); ref .read(canvasSelectionProvider.notifier) - .moveSelected(_isAttack ? world : -world); + .moveItems(drag.ids, _isAttack ? world : -world); } - void _onDragCancel() { + void _endDrag() { + _dragPointer = null; + _dragIds = null; _dragOrigin = null; - ref.read(canvasGroupDragProvider.notifier).set(null); + if (ref.read(canvasGroupDragProvider) != null) { + ref.read(canvasGroupDragProvider.notifier).set(null); + } } - /// How far the selection can be dragged each way, in canvas pixels, before - /// the middle of something in it would leave the map. - Rect _roomToMove() { + /// 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 ids = { - for (final item in ref.read(canvasSelectionProvider)) - if (movesWithSelection(item.kind)) item.id, - }; final points = [ for (final (:item, :corners) in _registry.itemsIn(canvas)) if (ids.contains(item.id)) _middle(corners), @@ -483,7 +513,7 @@ class _CanvasSelectionLayerState extends ConsumerState final ids = ref.watch(selectedDrawingIdsProvider); if (ids.isEmpty) return const []; // They follow a drag of the selection, as the drawings do. - final shift = ref.watch(canvasGroupDragProvider) ?? Offset.zero; + final drag = ref.watch(canvasGroupDragProvider); return [ for (final drawing in ref.watch(drawingProvider).elements) if (ids.contains(drawing.id)) @@ -492,7 +522,11 @@ class _CanvasSelectionLayerState extends ConsumerState .inflate( CoordinateSystem.instance.scale(drawing.thickness) / 2, ) - .shift(shift), + .shift( + drag != null && drag.ids.contains(drawing.id) + ? drag.by + : Offset.zero, + ), ]; } @@ -514,27 +548,31 @@ class _CanvasSelectionLayerState extends ConsumerState child: RawGestureDetector( behavior: HitTestBehavior.opaque, gestures: { - if (selecting) + // 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 = _onDragCancel, + ..onCancel = _endDrag, ), - if (selecting) - PanGestureRecognizer: GestureRecognizerFactoryWithHandlers< - PanGestureRecognizer>( - () => PanGestureRecognizer( - supportedDevices: const {PointerDeviceKind.mouse}, - allowedButtonsFilter: (buttons) => - buttons == kPrimaryMouseButton, - ), + 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 @@ -644,55 +682,43 @@ class _SelectionBoxPainter extends CustomPainter { box != oldDelegate.box || zoom != oldDelegate.zoom; } -/// Takes a press on the selection away from the item under it the moment it -/// lands, so the whole selection is dragged rather than that one item. -class _SelectionDragRecognizer extends OneSequenceGestureRecognizer { +/// 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}, + supportedDevices: const { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + }, allowedButtonsFilter: (buttons) => buttons == kPrimaryMouseButton, ); late bool Function(PointerDownEvent event) grabs; - late void Function(PointerDownEvent event) onStart; - late void Function(Offset local) onUpdate; - late VoidCallback onEnd; - late VoidCallback onCancel; - - @override - bool isPointerAllowed(PointerDownEvent event) => - super.isPointerAllowed(event) && grabs(event); @override - void addAllowedPointer(PointerDownEvent event) { - super.addAllowedPointer(event); - // Won before the item's own drag can start. - resolve(GestureDisposition.accepted); - onStart(event); - } - - @override - void handleEvent(PointerEvent event) { - if (event is PointerMoveEvent) { - onUpdate(event.localPosition); - } else if (event is PointerUpEvent) { - onEnd(); - stopTrackingPointer(event.pointer); - } else if (event is PointerCancelEvent) { - onCancel(); - stopTrackingPointer(event.pointer); - } - } + bool isPointerAllowed(PointerEvent event) => + super.isPointerAllowed(event) && + (event is! PointerDownEvent || grabs(event)); +} - @override - void rejectGesture(int pointer) { - onCancel(); - super.rejectGesture(pointer); - } +/// 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, + ); - @override - void didStopTrackingLastPointer(int pointer) {} + late bool Function(PointerDownEvent event) allows; @override - String get debugDescription => 'selection drag'; + 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 8e3e00946..37e38dea0 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 4f85caa98..8edebb601 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 9b4405567..2433f0903 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 af07c6c42..b52769402 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 b7bb50b68..73cffeb11 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 f6d747c1c..9a38b4f37 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 7184351ea..b1c4e3f5d 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 6d439f2d0..fc143649e 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 8d97c10ff..6f6289307 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 615275243..d748de85f 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 a8ed6d0e2..8c0fb5b84 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 9af46ae0f..c799162be 100644 --- a/lib/widgets/drawing_painter.dart +++ b/lib/widgets/drawing_painter.dart @@ -69,17 +69,16 @@ class _InteractivePainterState extends ConsumerState { // 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 == null - ? const {} - : ref.watch(selectedDrawingIdsProvider); + final shiftedIds = groupDrag?.ids ?? const {}; CustomPainter drawingPainter = DrawingPainter( updateCounter: drawingState.updateCounter, coordinateSystem: coordinateSystem, elements: drawingState.elements, // Pass the data directly currentLine: drawingState.currentElement, shiftedIds: shiftedIds, - shift: - isAttack ? groupDrag ?? Offset.zero : -(groupDrag ?? Offset.zero)); + shift: isAttack + ? groupDrag?.by ?? Offset.zero + : -(groupDrag?.by ?? Offset.zero)); final currentInteractionState = ref.watch(interactionStateProvider); diff --git a/lib/widgets/editor_operation_scope.dart b/lib/widgets/editor_operation_scope.dart index d51351365..40a511ed2 100644 --- a/lib/widgets/editor_operation_scope.dart +++ b/lib/widgets/editor_operation_scope.dart @@ -128,11 +128,13 @@ 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, a selected item is drawn where the + // 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 dragged = movesWithSelection(kind) && - ref.watch(selectedCanvasIdsProvider.select((ids) => ids.contains(id))); - final shift = dragged ? ref.watch(canvasGroupDragProvider) : null; + final shift = ref.watch( + canvasGroupDragProvider.select( + (drag) => drag != null && drag.ids.contains(id) ? drag.by : null, + ), + ); return Positioned.fill( child: Listener( behavior: HitTestBehavior.deferToChild, @@ -141,10 +143,13 @@ class EditorEntityLayer extends ConsumerWidget { // Always here, so the item keeps its state when a drag starts. child: Transform.translate( offset: shift ?? Offset.zero, - child: ItemOutlineScope( - id: id, - kind: kind, - child: Stack(clipBehavior: Clip.none, children: [child]), + child: _DragsWithSelection( + value: ref.watch(dragsWithSelectionProvider(id)), + child: ItemOutlineScope( + id: id, + kind: kind, + child: Stack(clipBehavior: Clip.none, children: [child]), + ), ), ), ), @@ -152,6 +157,28 @@ class EditorEntityLayer extends ConsumerWidget { } } +/// 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 7bbc0b845..50e3ce06b 100644 --- a/test/canvas_selection_layer_test.dart +++ b/test/canvas_selection_layer_test.dart @@ -36,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), + ), + ), + ), + ], ), ), ), @@ -109,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)), ], @@ -273,7 +297,7 @@ void main() { const Offset(160, 130), ); // Drawn where the drag has carried them, saved where they were. - expect(container.read(canvasGroupDragProvider), const Offset(50, 20)); + expect(container.read(canvasGroupDragProvider)?.by, const Offset(50, 20)); expect(positionOf('a'), Offset.zero); await gesture.up(); await tester.pump(); @@ -338,4 +362,84 @@ void main() { 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 1d4b2064c..1b4463764 100644 --- a/test/canvas_selection_test.dart +++ b/test/canvas_selection_test.dart @@ -155,7 +155,7 @@ void main() { final selection = container.read(canvasSelectionProvider.notifier); selection.select([_a, (kind: SelectableKind.drawing, id: 'd')]); - selection.moveSelected(const Offset(50, 20)); + selection.moveItems({'a', 'd'}, const Offset(50, 20)); Offset agentAt(String id) => container .read(agentProvider)