diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 5f1f40f9..b2f7daba 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -340,11 +340,16 @@ class PlacedImageProvider extends Notifier { return images.map(_migrateLoadedImage).toList(); } - void updateScale(int index, double scale) { + /// Sets an image's scale and position together: on defense a resize moves + /// the stored position so the image's on-screen top-left stays put. + void resize(String id, {required double scale, required Offset position}) { final newState = state.copyWith(); + final index = PlacedWidget.getIndexByID(id, newState.images); + if (index < 0) return; - newState.images[index].scale = ImageScalePolicy.clamp(scale); - + newState.images[index] + ..scale = ImageScalePolicy.clamp(scale) + ..position = position; state = newState; } diff --git a/lib/providers/text_provider.dart b/lib/providers/text_provider.dart index b220dabe..92ec180f 100644 --- a/lib/providers/text_provider.dart +++ b/lib/providers/text_provider.dart @@ -161,11 +161,16 @@ class TextProvider extends Notifier> { state = newState; } - void updateSize(int index, double size) { - final newState = [...state]; - if (index < 0 || index >= newState.length) return; + /// Sets a text's width and position together: on defense a resize moves + /// the stored position so the box's on-screen top-left stays put. + void resize(String id, {required double size, required Offset position}) { + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; - newState[index].size = size; + final newState = [...state]; + newState[index] + ..size = size + ..position = position; state = newState; } diff --git a/lib/widgets/draggable_widgets/canonical_positioned.dart b/lib/widgets/draggable_widgets/canonical_positioned.dart index fbffba7f..ff538b8e 100644 --- a/lib/widgets/draggable_widgets/canonical_positioned.dart +++ b/lib/widgets/draggable_widgets/canonical_positioned.dart @@ -11,12 +11,20 @@ class CanonicalPositionedBox extends StatelessWidget { required this.attackScreenPosition, required this.isAttack, required this.child, + this.pinnedScreenPosition, }); final Offset attackScreenPosition; final bool isAttack; final Widget child; + /// While set, the box keeps this on-screen top-left whatever its size. + /// + /// On defense the box mirrors attack, so it hangs from its bottom-right + /// corner and growing it would move its top and left edges. A resize pins + /// the top-left here so only the dragged edge moves. + final Offset? pinnedScreenPosition; + @override Widget build(BuildContext context) { return Positioned.fill( @@ -24,6 +32,7 @@ class CanonicalPositionedBox extends StatelessWidget { delegate: _CanonicalBoxPositionDelegate( attackScreenPosition: attackScreenPosition, isAttack: isAttack, + pinnedScreenPosition: pinnedScreenPosition, ), child: child, ), @@ -35,10 +44,12 @@ class _CanonicalBoxPositionDelegate extends SingleChildLayoutDelegate { const _CanonicalBoxPositionDelegate({ required this.attackScreenPosition, required this.isAttack, + required this.pinnedScreenPosition, }); final Offset attackScreenPosition; final bool isAttack; + final Offset? pinnedScreenPosition; @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { @@ -47,6 +58,7 @@ class _CanonicalBoxPositionDelegate extends SingleChildLayoutDelegate { @override Offset getPositionForChild(Size size, Size childSize) { + if (pinnedScreenPosition case final pinned?) return pinned; if (isAttack) return attackScreenPosition; return Offset( size.width - attackScreenPosition.dx - childSize.width, @@ -57,6 +69,7 @@ class _CanonicalBoxPositionDelegate extends SingleChildLayoutDelegate { @override bool shouldRelayout(covariant _CanonicalBoxPositionDelegate oldDelegate) { return attackScreenPosition != oldDelegate.attackScreenPosition || - isAttack != oldDelegate.isAttack; + isAttack != oldDelegate.isAttack || + pinnedScreenPosition != oldDelegate.pinnedScreenPosition; } } diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index 602c9fa4..c57745c4 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -10,6 +10,7 @@ import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/draggable_widgets/adjacent_page_copy_menu.dart'; +import 'package:icarus/widgets/draggable_widgets/canonical_positioned.dart'; import 'package:icarus/widgets/draggable_widgets/image/image_widget.dart'; import 'package:icarus/widgets/draggable_widgets/image/scalable_widget.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; @@ -19,6 +20,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; class PlacedImageBuilder extends StatefulWidget { const PlacedImageBuilder({ required this.placedImage, + required this.isAttack, required this.onDragEnd, required this.scale, super.key, @@ -26,15 +28,22 @@ class PlacedImageBuilder extends StatefulWidget { final double scale; final PlacedImage placedImage; + final bool isAttack; final Function(DraggableDetails details) onDragEnd; @override State createState() => _PlacedImageBuilderState(); } class _PlacedImageBuilderState extends State { + final _boxKey = GlobalKey(); double? localScale; // Make localScale nullable to check if it's initialized bool isPanning = false; bool isDragging = false; + Offset? pinnedScreenPosition; + + /// How far a mid-resize side switch moved the image away from the held + /// pointer, so the width keeps following the pointer's movement. + double pointerShift = 0; @override void initState() { @@ -42,6 +51,83 @@ class _PlacedImageBuilderState extends State { localScale ??= ImageScalePolicy.clamp(widget.scale); } + /// A side switch mid-resize moves the pin to where the image now shows, + /// so the position stored on release is read on the side it was pinned on. + /// The image leaves the held pointer behind, so later drag updates measure + /// from where the left edge was. + @override + void didUpdateWidget(covariant PlacedImageBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + final pinned = pinnedScreenPosition; + final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (pinned == null || + renderBox == null || + oldWidget.isAttack == widget.isAttack) { + return; + } + final coordinateSystem = CoordinateSystem.instance; + final boxSize = renderBox.size.bottomRight(Offset.zero); + final nextPinned = coordinateSystem.screenPositionForSide( + attackScreenPosition: coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: boxSize, + isAttack: oldWidget.isAttack, + ), + reflectionOffset: boxSize, + isAttack: widget.isAttack, + ); + pointerShift += nextPinned.dx - pinned.dx; + pinnedScreenPosition = nextPinned; + } + + /// Stores the new scale at once, so a page save right after release keeps + /// it, with the position that keeps the image's top-left where the resize + /// pinned it. On defense the image hangs from its bottom-right corner, so + /// that position moves with the size. + /// + /// The last drag update may not be laid out yet, so the pin holds for one + /// more frame, then the position is stored again if it moved, unless a + /// page switch loaded another page's copy of the image, which keeps its id. + Future _finishResize(WidgetRef ref) async { + final resizedImage = widget.placedImage; + final pinned = pinnedScreenPosition; + if (pinned != null) _storeResize(ref, resizedImage, pinned); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + final stillLoaded = ref + .read(placedImageProvider) + .images + .any((image) => identical(image, resizedImage)); + if (pinned != null && stillLoaded) _storeResize(ref, resizedImage, pinned); + setState(() { + isPanning = false; + pinnedScreenPosition = null; + pointerShift = 0; + }); + } + + /// Writes [image]'s new scale and the position that puts its top-left at + /// [pinned] as laid out now, unless both are already stored. + void _storeResize(WidgetRef ref, PlacedImage image, Offset pinned) { + final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final coordinateSystem = CoordinateSystem.instance; + final position = coordinateSystem.screenToCoordinate( + coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ), + ); + if (image.scale == localScale && image.position == position) return; + ref + .read(placedImageProvider.notifier) + .resize(image.id, scale: localScale!, position: position); + ref.read(strategyProvider.notifier).setUnsaved(); + } + @override Widget build(BuildContext context) { if (localScale == null) { @@ -58,83 +144,86 @@ class _PlacedImageBuilderState extends State { ref.read(placedImageProvider).images[index].scale); } - return ImageScaleController( - isDragging: isDragging, - onPanUpdate: (details) { - final renderBox = context.findRenderObject() as RenderBox?; - if (renderBox == null) return; - - final topLeftGlobal = renderBox.localToGlobal(Offset.zero); - final screenZoom = ref.read(screenZoomProvider); - final widthInScreenPixels = - details.globalPosition.dx - topLeftGlobal.dx; - final widthInContentSpace = widthInScreenPixels / screenZoom; - final widthInWorldSpace = - CoordinateSystem.instance.screenWidthToWorld(widthInContentSpace); - - setState(() { - isPanning = true; - localScale = ImageScalePolicy.clamp(widthInWorldSpace); - }); - }, - onPanEnd: (details) { - final index = PlacedWidget.getIndexByID( - widget.placedImage.id, - ref.read(placedImageProvider).images, - ); - ref - .read(placedImageProvider.notifier) - .updateScale(index, localScale!); - ref.read(strategyProvider.notifier).setUnsaved(); + final coordinateSystem = CoordinateSystem.instance; + final attackScreenPosition = + coordinateSystem.coordinateToScreen(widget.placedImage.position); + return CanonicalPositionedBox( + attackScreenPosition: attackScreenPosition, + isAttack: widget.isAttack, + pinnedScreenPosition: pinnedScreenPosition, + child: ImageScaleController( + key: _boxKey, + isDragging: isDragging, + onPanUpdate: (details) { + final renderBox = + _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final topLeftGlobal = renderBox.localToGlobal(Offset.zero); + final screenZoom = ref.read(screenZoomProvider); + final widthInScreenPixels = + details.globalPosition.dx - topLeftGlobal.dx; + final widthInContentSpace = + widthInScreenPixels / screenZoom + pointerShift; + final widthInWorldSpace = + coordinateSystem.screenWidthToWorld(widthInContentSpace); - setState(() { - isPanning = false; - }); - }, - child: Draggable( - data: widget.placedImage, - feedback: ZoomTransform( - child: IgnorePointer( - child: ImageWidget( - isFeedback: true, - link: widget.placedImage.link, - aspectRatio: widget.placedImage.aspectRatio, - scale: localScale!, - fileExtension: widget.placedImage.fileExtension, - id: widget.placedImage.id, - tagColorValue: widget.placedImage.tagColorValue, - ), - ), - ), - childWhenDragging: const SizedBox.shrink(), - dragAnchorStrategy: - ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, - onDragStarted: () { - setState(() { - isDragging = true; - }); - }, - onDragEnd: (details) { - widget.onDragEnd(details); setState(() { - isDragging = false; + isPanning = true; + pinnedScreenPosition ??= coordinateSystem.screenPositionForSide( + attackScreenPosition: attackScreenPosition, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ); + localScale = ImageScalePolicy.clamp(widthInWorldSpace); }); }, - child: ShadContextMenuRegion( - items: _buildTagColorItems(ref), - child: MouseWatch( - cursor: SystemMouseCursors.click, - deleteTarget: HoveredDeleteTarget.image( - id: widget.placedImage.id, - ownerToken: Object(), + onPanEnd: (_) => _finishResize(ref), + child: Draggable( + data: widget.placedImage, + feedback: ZoomTransform( + child: IgnorePointer( + child: ImageWidget( + isFeedback: true, + link: widget.placedImage.link, + aspectRatio: widget.placedImage.aspectRatio, + scale: localScale!, + fileExtension: widget.placedImage.fileExtension, + id: widget.placedImage.id, + tagColorValue: widget.placedImage.tagColorValue, + ), ), - child: ImageWidget( - fileExtension: widget.placedImage.fileExtension, - aspectRatio: widget.placedImage.aspectRatio, - link: widget.placedImage.link, - scale: localScale!, - id: widget.placedImage.id, - tagColorValue: widget.placedImage.tagColorValue, + ), + childWhenDragging: const SizedBox.shrink(), + dragAnchorStrategy: + ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, + onDragStarted: () { + setState(() { + isDragging = true; + }); + }, + onDragEnd: (details) { + widget.onDragEnd(details); + setState(() { + isDragging = false; + }); + }, + child: ShadContextMenuRegion( + items: _buildTagColorItems(ref), + child: MouseWatch( + cursor: SystemMouseCursors.click, + deleteTarget: HoveredDeleteTarget.image( + id: widget.placedImage.id, + ownerToken: Object(), + ), + child: ImageWidget( + fileExtension: widget.placedImage.fileExtension, + aspectRatio: widget.placedImage.aspectRatio, + link: widget.placedImage.link, + scale: localScale!, + id: widget.placedImage.id, + tagColorValue: widget.placedImage.tagColorValue, + ), ), ), ), diff --git a/lib/widgets/draggable_widgets/placed_widget_builder.dart b/lib/widgets/draggable_widgets/placed_widget_builder.dart index 0defd84d..ad131165 100644 --- a/lib/widgets/draggable_widgets/placed_widget_builder.dart +++ b/lib/widgets/draggable_widgets/placed_widget_builder.dart @@ -33,7 +33,6 @@ import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart'; import 'package:icarus/widgets/draggable_widgets/image/placed_image_builder.dart'; import 'package:icarus/widgets/draggable_widgets/ability/placed_ability_widget.dart'; -import 'package:icarus/widgets/draggable_widgets/canonical_positioned.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart'; @@ -702,46 +701,41 @@ class _TextList extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final placedText in placedTexts) - CanonicalPositionedBox( + PlacedTextBuilder( key: ValueKey(placedText.id), - attackScreenPosition: coordinateSystem.coordinateToScreen( - placedText.position, - ), + size: placedText.size, + placedText: placedText, isAttack: isAttack, - child: PlacedTextBuilder( - size: placedText.size, - placedText: placedText, - onDragEnd: (details) { - final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal(details.offset); - final renderedSize = ref - .read(textWidgetHeightProvider.notifier) - .getOffset(placedText.id); - final attackScreenOffset = - coordinateSystem.screenPositionFromSide( - sideScreenPosition: localOffset, - reflectionOffset: renderedSize, - isAttack: isAttack, - ); - final virtualOffset = coordinateSystem.screenToCoordinate( - attackScreenOffset, - ); - final safeArea = agentSize / 2; - - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea, safeArea), - )) { - ref - .read(textProvider.notifier) - .removeTextAsAction(placedText.id); - return; - } + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal(details.offset); + final renderedSize = ref + .read(textWidgetHeightProvider.notifier) + .getOffset(placedText.id); + final attackScreenOffset = + coordinateSystem.screenPositionFromSide( + sideScreenPosition: localOffset, + reflectionOffset: renderedSize, + isAttack: isAttack, + ); + final virtualOffset = coordinateSystem.screenToCoordinate( + attackScreenOffset, + ); + final safeArea = agentSize / 2; + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea, safeArea), + )) { ref .read(textProvider.notifier) - .updatePosition(virtualOffset, placedText.id); - }, - ), + .removeTextAsAction(placedText.id); + return; + } + + ref + .read(textProvider.notifier) + .updatePosition(virtualOffset, placedText.id); + }, ), ], ); @@ -766,46 +760,41 @@ class _PlacedImageList extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final placedImage in images) - CanonicalPositionedBox( + PlacedImageBuilder( key: ValueKey(placedImage.id), - attackScreenPosition: coordinateSystem.coordinateToScreen( - placedImage.position, - ), + placedImage: placedImage, isAttack: isAttack, - child: PlacedImageBuilder( - placedImage: placedImage, - scale: placedImage.scale, - onDragEnd: (details) { - final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal(details.offset); - final renderedSize = ref - .read(imageWidgetSizeProvider.notifier) - .getSize(placedImage.id); - final attackScreenOffset = - coordinateSystem.screenPositionFromSide( - sideScreenPosition: localOffset, - reflectionOffset: renderedSize, - isAttack: isAttack, - ); - final virtualOffset = coordinateSystem.screenToCoordinate( - attackScreenOffset, - ); - final safeArea = agentSize / 2; - - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea, safeArea), - )) { - ref - .read(placedImageProvider.notifier) - .removeImageAsAction(placedImage.id); - return; - } + scale: placedImage.scale, + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal(details.offset); + final renderedSize = ref + .read(imageWidgetSizeProvider.notifier) + .getSize(placedImage.id); + final attackScreenOffset = + coordinateSystem.screenPositionFromSide( + sideScreenPosition: localOffset, + reflectionOffset: renderedSize, + isAttack: isAttack, + ); + final virtualOffset = coordinateSystem.screenToCoordinate( + attackScreenOffset, + ); + final safeArea = agentSize / 2; + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea, safeArea), + )) { ref .read(placedImageProvider.notifier) - .updatePosition(virtualOffset, placedImage.id); - }, - ), + .removeImageAsAction(placedImage.id); + return; + } + + ref + .read(placedImageProvider.notifier) + .updatePosition(virtualOffset, placedImage.id); + }, ), ], ); diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index 13361cfc..4dad542a 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -8,6 +8,7 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/widgets/draggable_widgets/adjacent_page_copy_menu.dart'; +import 'package:icarus/widgets/draggable_widgets/canonical_positioned.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_scale_controller.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; @@ -19,10 +20,12 @@ class PlacedTextBuilder extends ConsumerStatefulWidget { super.key, required this.size, required this.placedText, + required this.isAttack, required this.onDragEnd, }); final double size; final PlacedText placedText; + final bool isAttack; final Function(DraggableDetails details) onDragEnd; @override ConsumerState createState() => @@ -38,15 +41,98 @@ class _PlacedTextBuilderState extends ConsumerState { Color(0xFFEF4444), Color(0xFFA855F7), ]; + final _boxKey = GlobalKey(); double? localSize; // Make localScale nullable to check if it's initialized bool isPanning = false; bool isDragging = false; + Offset? pinnedScreenPosition; + + /// How far a mid-resize side switch moved the box away from the held + /// pointer, so the width keeps following the pointer's movement. + double pointerShift = 0; @override void initState() { localSize ??= widget.size; super.initState(); } + /// A side switch mid-resize moves the pin to where the box now shows, + /// so the position stored on release is read on the side it was pinned on. + /// The box leaves the held pointer behind, so later drag updates measure + /// from where the left edge was. + @override + void didUpdateWidget(covariant PlacedTextBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + final pinned = pinnedScreenPosition; + final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (pinned == null || + renderBox == null || + oldWidget.isAttack == widget.isAttack) { + return; + } + final coordinateSystem = CoordinateSystem.instance; + final boxSize = renderBox.size.bottomRight(Offset.zero); + final nextPinned = coordinateSystem.screenPositionForSide( + attackScreenPosition: coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: boxSize, + isAttack: oldWidget.isAttack, + ), + reflectionOffset: boxSize, + isAttack: widget.isAttack, + ); + pointerShift += nextPinned.dx - pinned.dx; + pinnedScreenPosition = nextPinned; + } + + /// Stores the new width at once, so a page save right after release keeps + /// it, with the position that keeps the box's top-left where the resize + /// pinned it. On defense the box hangs from its bottom-right corner, so + /// that position moves with the size. + /// + /// The text's height, and so its defense position, follows from its width, + /// and the last drag update may not be laid out yet. So the pin holds for + /// one more frame, then the position is stored again if it moved, unless a + /// page switch loaded another page's copy of the text, which keeps its id. + Future _finishResize() async { + final resizedText = widget.placedText; + final pinned = pinnedScreenPosition; + if (pinned != null) _storeResize(resizedText, pinned); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + final stillLoaded = + ref.read(textProvider).any((text) => identical(text, resizedText)); + if (pinned != null && stillLoaded) _storeResize(resizedText, pinned); + setState(() { + isPanning = false; + isDragging = false; + pinnedScreenPosition = null; + pointerShift = 0; + }); + } + + /// Writes [text]'s new width and the position that puts its top-left at + /// [pinned] as laid out now, unless both are already stored. + void _storeResize(PlacedText text, Offset pinned) { + final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final coordinateSystem = CoordinateSystem.instance; + final position = coordinateSystem.screenToCoordinate( + coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ), + ); + if (text.size == localSize && text.position == position) return; + ref + .read(textProvider.notifier) + .resize(text.id, size: localSize!, position: position); + ref.read(strategyProvider.notifier).setUnsaved(); + } + @override Widget build(BuildContext context) { final texts = ref.watch(textProvider); @@ -65,82 +151,89 @@ class _PlacedTextBuilderState extends ConsumerState { if (texts[index].size != localSize && !isPanning) { localSize = texts[index].size; } - return TextScaleController( - isDragging: isDragging, - onPanUpdate: (details) { - final renderBox = context.findRenderObject() as RenderBox?; - if (renderBox == null) return; + final coordinateSystem = CoordinateSystem.instance; + final attackScreenPosition = + coordinateSystem.coordinateToScreen(widget.placedText.position); + return CanonicalPositionedBox( + attackScreenPosition: attackScreenPosition, + isAttack: widget.isAttack, + pinnedScreenPosition: pinnedScreenPosition, + child: TextScaleController( + key: _boxKey, + isDragging: isDragging, + onPanUpdate: (details) { + final renderBox = + _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; - final leftEdgeGlobal = renderBox.localToGlobal(Offset.zero); - final scale = ref.read(screenZoomProvider); - final widthInScreenPixels = - details.globalPosition.dx - leftEdgeGlobal.dx; - final widthInContentSpace = widthInScreenPixels / scale; - final widthInWorldSpace = - CoordinateSystem.instance.screenWidthToWorld(widthInContentSpace); + final leftEdgeGlobal = renderBox.localToGlobal(Offset.zero); + final scale = ref.read(screenZoomProvider); + final widthInScreenPixels = + details.globalPosition.dx - leftEdgeGlobal.dx; + final widthInContentSpace = + widthInScreenPixels / scale + pointerShift; + final widthInWorldSpace = + coordinateSystem.screenWidthToWorld(widthInContentSpace); - setState(() { - isPanning = true; - localSize = widthInWorldSpace.clamp(minSize, double.infinity); - }); - }, - onPanEnd: (details) { - final index = PlacedWidget.getIndexByID( - widget.placedText.id, ref.watch(textProvider)); - setState(() { - isPanning = false; - isDragging = false; - }); - ref.read(textProvider.notifier).updateSize(index, localSize!); - ref.read(strategyProvider.notifier).setUnsaved(); - }, - child: Draggable( - data: widget.placedText, - feedback: Opacity( - opacity: 0.8, - child: ZoomTransform( - child: TextWidget( - id: widget.placedText.id, - text: draftText ?? widget.placedText.text, - size: localSize!, - fontSize: widget.placedText.fontSize, - tagColorValue: widget.placedText.tagColorValue, - isFeedback: true, - ), - ), - ), - childWhenDragging: const SizedBox.shrink(), - dragAnchorStrategy: - ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, - onDragStarted: () { - ref - .read(textDraftProvider.notifier) - .commitDraft(widget.placedText.id); setState(() { - isDragging = true; + isPanning = true; + pinnedScreenPosition ??= coordinateSystem.screenPositionForSide( + attackScreenPosition: attackScreenPosition, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ); + localSize = widthInWorldSpace.clamp(minSize, double.infinity); }); }, - onDragEnd: (details) { - widget.onDragEnd(details); - setState(() { - isDragging = false; - }); - }, - child: ShadContextMenuRegion( - items: _buildTagColorItems(), - child: MouseWatch( - cursor: SystemMouseCursors.click, - deleteTarget: HoveredDeleteTarget.text( - id: widget.placedText.id, - ownerToken: Object(), + onPanEnd: (_) => _finishResize(), + child: Draggable( + data: widget.placedText, + feedback: Opacity( + opacity: 0.8, + child: ZoomTransform( + child: TextWidget( + id: widget.placedText.id, + text: draftText ?? widget.placedText.text, + size: localSize!, + fontSize: widget.placedText.fontSize, + tagColorValue: widget.placedText.tagColorValue, + isFeedback: true, + ), ), - child: TextWidget( - id: widget.placedText.id, - text: widget.placedText.text, - size: localSize!, - fontSize: widget.placedText.fontSize, - tagColorValue: widget.placedText.tagColorValue, - isFeedback: false, + ), + childWhenDragging: const SizedBox.shrink(), + dragAnchorStrategy: + ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, + onDragStarted: () { + ref + .read(textDraftProvider.notifier) + .commitDraft(widget.placedText.id); + setState(() { + isDragging = true; + }); + }, + onDragEnd: (details) { + widget.onDragEnd(details); + setState(() { + isDragging = false; + }); + }, + child: ShadContextMenuRegion( + items: _buildTagColorItems(), + child: MouseWatch( + cursor: SystemMouseCursors.click, + deleteTarget: HoveredDeleteTarget.text( + id: widget.placedText.id, + ownerToken: Object(), + ), + child: TextWidget( + id: widget.placedText.id, + text: widget.placedText.text, + size: localSize!, + fontSize: widget.placedText.fontSize, + tagColorValue: widget.placedText.tagColorValue, + isFeedback: false, + ), ), ), ), diff --git a/test/placed_box_resize_test.dart b/test/placed_box_resize_test.dart new file mode 100644 index 00000000..0f2f45a3 --- /dev/null +++ b/test/placed_box_resize_test.dart @@ -0,0 +1,357 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/color_library_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/image/placed_image_builder.dart'; +import 'package:icarus/widgets/draggable_widgets/image/scalable_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; +import 'package:icarus/widgets/draggable_widgets/text/text_scale_controller.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class _FixedStorageStrategyProvider extends StrategyProvider { + @override + StrategyState build() => StrategyState( + isSaved: false, + stratName: null, + id: 'placed-box-resize-test', + storageDirectory: Directory.systemTemp.path, + ); +} + +void main() { + const playArea = Size(1600, 900); + const originalPosition = Offset(500, 400); + + Future pumpBox( + WidgetTester tester, { + required Widget Function(WidgetRef ref, bool isAttack) builder, + required ValueNotifier side, + required void Function(ProviderContainer container) seed, + }) async { + tester.view.physicalSize = playArea; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + CoordinateSystem(playAreaSize: playArea); + + // Images read the strategy's storage folder and, for their tag menu, the + // color library, which lives in Hive. + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_FixedStorageStrategyProvider.new), + colorLibraryProvider.overrideWith( + (ref) => const [ + ColorLibraryEntry(color: Colors.white, isCustom: false), + ], + ), + ], + ); + addTearDown(container.dispose); + seed(container); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp( + home: Consumer( + builder: (context, ref, _) => ValueListenableBuilder( + valueListenable: side, + builder: (context, isAttack, _) => + Stack(children: [builder(ref, isAttack)]), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return container; + } + + /// Drags the resize handle right by [dx], checking at every step that the + /// box's top-left stays where the user sees it. + Future dragHandleBy( + WidgetTester tester, { + required Finder box, + required MouseCursor handleCursor, + required double dx, + }) async { + final handle = find.byWidgetPredicate( + (widget) => widget is MouseRegion && widget.cursor == handleCursor, + ); + final start = tester.getRect(box); + final gesture = await tester.startGesture(tester.getCenter(handle)); + for (var moved = 0.0; moved < dx; moved += 20) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(tester.getRect(box).topLeft, start.topLeft); + } + await gesture.up(); + await tester.pumpAndSettle(); + } + + for (final isAttack in [true, false]) { + final side = isAttack ? 'attack' : 'defense'; + + testWidgets('resizing text on $side moves only its right edge', + (tester) async { + final container = await pumpBox( + tester, + seed: (container) => container.read(textProvider.notifier).fromHive([ + PlacedText( + id: 'text-1', + position: originalPosition, + size: 200, + sizeVersion: PlacedText.currentSizeVersion, + )..text = 'Yo text boxes resize properly on both sides now', + ]), + side: ValueNotifier(isAttack), + builder: (ref, isAttack) { + final placedText = ref.watch(textProvider).single; + return PlacedTextBuilder( + key: ValueKey(placedText.id), + size: placedText.size, + placedText: placedText, + isAttack: isAttack, + onDragEnd: (_) {}, + ); + }, + ); + final box = find.byType(TextScaleController); + final before = tester.getRect(box); + + await dragHandleBy( + tester, + box: box, + handleCursor: SystemMouseCursors.resizeLeftRight, + dx: 120, + ); + + // Once released, the stored position alone keeps the box in place. + final after = tester.getRect(box); + expect(after.topLeft, offsetMoreOrLessEquals(before.topLeft)); + expect(after.width, closeTo(before.width + 120, 10)); + expect(after.height, lessThan(before.height)); + + final stored = container.read(textProvider).single; + expect(stored.size, greaterThan(200)); + if (isAttack) { + expect(stored.position, offsetMoreOrLessEquals(originalPosition)); + } + }); + + testWidgets('resizing an image on $side keeps its top-left in place', + (tester) async { + final container = await pumpBox( + tester, + seed: (container) => + container.read(placedImageProvider.notifier).fromHive([ + PlacedImage( + id: 'image-1', + position: originalPosition, + aspectRatio: 16 / 9, + scale: 200, + fileExtension: null, + sizeVersion: worldSizedMediaVersion, + ), + ]), + side: ValueNotifier(isAttack), + builder: (ref, isAttack) { + final placedImage = ref.watch(placedImageProvider).images.single; + return PlacedImageBuilder( + key: ValueKey(placedImage.id), + placedImage: placedImage, + isAttack: isAttack, + scale: placedImage.scale, + onDragEnd: (_) {}, + ); + }, + ); + final box = find.byType(ImageScaleController); + final before = tester.getRect(box); + + await dragHandleBy( + tester, + box: box, + handleCursor: SystemMouseCursors.resizeDownRight, + dx: 120, + ); + + final after = tester.getRect(box); + expect(after.topLeft, offsetMoreOrLessEquals(before.topLeft)); + expect(after.width, greaterThan(before.width + 100)); + + final stored = container.read(placedImageProvider).images.single; + expect(stored.scale, greaterThan(200)); + if (isAttack) { + expect(stored.position, offsetMoreOrLessEquals(originalPosition)); + } + }); + } + + testWidgets('switching sides mid-resize keeps the pinned side placement', + (tester) async { + final side = ValueNotifier(false); + final container = await pumpBox( + tester, + seed: (container) => container.read(textProvider.notifier).fromHive([ + PlacedText( + id: 'text-1', + position: originalPosition, + size: 200, + sizeVersion: PlacedText.currentSizeVersion, + )..text = 'Yo text boxes resize properly on both sides now', + ]), + side: side, + builder: (ref, isAttack) { + final placedText = ref.watch(textProvider).single; + return PlacedTextBuilder( + key: ValueKey(placedText.id), + size: placedText.size, + placedText: placedText, + isAttack: isAttack, + onDragEnd: (_) {}, + ); + }, + ); + final box = find.byType(TextScaleController); + final handle = find.byWidgetPredicate( + (widget) => + widget is MouseRegion && + widget.cursor == SystemMouseCursors.resizeLeftRight, + ); + + final gesture = await tester.startGesture(tester.getCenter(handle)); + for (var i = 0; i < 3; i++) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + final onDefense = tester.getRect(box); + + side.value = true; + await tester.pump(); + // The box mirrors away from the held pointer, but keeps tracking its + // movement rather than jumping to it. + final widthAfterSwitch = tester.getRect(box).width; + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(); + expect(tester.getRect(box).width, closeTo(widthAfterSwitch + 10, 1)); + await gesture.moveBy(const Offset(-10, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + side.value = false; + await tester.pumpAndSettle(); + expect( + tester.getRect(box).topLeft, offsetMoreOrLessEquals(onDefense.topLeft)); + expect(container.read(textProvider).single.size, greaterThan(200)); + }); + + testWidgets('a release before the next frame keeps its last movement', + (tester) async { + await pumpBox( + tester, + seed: (container) => container.read(textProvider.notifier).fromHive([ + PlacedText( + id: 'text-1', + position: originalPosition, + size: 200, + sizeVersion: PlacedText.currentSizeVersion, + )..text = 'Yo text boxes resize properly on both sides now', + ]), + side: ValueNotifier(false), + builder: (ref, isAttack) { + final placedText = ref.watch(textProvider).single; + return PlacedTextBuilder( + key: ValueKey(placedText.id), + size: placedText.size, + placedText: placedText, + isAttack: isAttack, + onDragEnd: (_) {}, + ); + }, + ); + final box = find.byType(TextScaleController); + final handle = find.byWidgetPredicate( + (widget) => + widget is MouseRegion && + widget.cursor == SystemMouseCursors.resizeLeftRight, + ); + + final gesture = await tester.startGesture(tester.getCenter(handle)); + for (var i = 0; i < 3; i++) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + final shown = tester.getRect(box); + + // A last move and the release arrive before the next frame draws it. + await gesture.moveBy(const Offset(40, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.getRect(box).topLeft, offsetMoreOrLessEquals(shown.topLeft)); + expect(tester.getRect(box).width, closeTo(shown.width + 40, 1)); + }); + + testWidgets('a page switch right after release keeps the resize on its page', + (tester) async { + final container = await pumpBox( + tester, + seed: (container) => container.read(textProvider.notifier).fromHive([ + PlacedText( + id: 'text-1', + position: originalPosition, + size: 200, + sizeVersion: PlacedText.currentSizeVersion, + )..text = 'Yo text boxes resize properly on both sides now', + ]), + side: ValueNotifier(false), + builder: (ref, isAttack) { + final placedText = ref.watch(textProvider).single; + return PlacedTextBuilder( + key: ValueKey(placedText.id), + size: placedText.size, + placedText: placedText, + isAttack: isAttack, + onDragEnd: (_) {}, + ); + }, + ); + final handle = find.byWidgetPredicate( + (widget) => + widget is MouseRegion && + widget.cursor == SystemMouseCursors.resizeLeftRight, + ); + + final gesture = await tester.startGesture(tester.getCenter(handle)); + for (var i = 0; i < 3; i++) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + final resized = container.read(textProvider).single; + await gesture.up(); + + // Before the frame, the page is saved and the next page loads its copy + // of the text. The save must already see the new width. + expect(resized.size, greaterThan(200)); + final copy = PlacedText( + id: 'text-1', + position: const Offset(100, 100), + size: 200, + sizeVersion: PlacedText.currentSizeVersion, + )..text = 'Yo text boxes resize properly on both sides now'; + container.read(textProvider.notifier).fromHive([copy]); + await tester.pumpAndSettle(); + + final loaded = container.read(textProvider).single; + expect(loaded.size, 200); + expect(loaded.position, const Offset(100, 100)); + }); +} diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index dee4db71..628df917 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -104,14 +104,11 @@ void main() { final placedText = ref.watch(textProvider).first; return Stack( children: [ - Positioned( - left: 20, - top: 20, - child: PlacedTextBuilder( - size: placedText.size, - placedText: placedText, - onDragEnd: (_) {}, - ), + PlacedTextBuilder( + size: placedText.size, + placedText: placedText, + isAttack: true, + onDragEnd: (_) {}, ), ], );