From d79ee95ced17cbd2a16efff51771a860085ef6a8 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 29 Sep 2026 00:40:32 -0400 Subject: [PATCH 1/6] Keep text and images in place when resized on defense On defense a text box or image mirrors attack, so it hangs from its bottom-right corner. Dragging the right-hand resize handle moved the left edge instead, and because the width was measured from that moving edge, the box ran away from the pointer. A resize now pins the box's on-screen top-left, and on release stores the size together with the canonical position that keeps it there. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/providers/image_provider.dart | 11 +- lib/providers/text_provider.dart | 13 +- .../canonical_positioned.dart | 15 +- .../image/placed_image_builder.dart | 181 ++++++++++------- .../placed_widget_builder.dart | 131 ++++++------ .../text/placed_text_builder.dart | 184 ++++++++++------- test/placed_box_resize_test.dart | 189 ++++++++++++++++++ test/text_widget_resilience_test.dart | 13 +- 8 files changed, 509 insertions(+), 228 deletions(-) create mode 100644 test/placed_box_resize_test.dart 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..32b4eebe 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,18 @@ 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; @override void initState() { @@ -42,6 +47,38 @@ class _PlacedImageBuilderState extends State { localScale ??= ImageScalePolicy.clamp(widget.scale); } + /// Stores the new scale, and 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. + /// + /// Waits for the frame that lays out the last drag update, so the stored + /// position matches the size the user let go at. + Future _finishResize(WidgetRef ref) async { + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + final pinned = pinnedScreenPosition; + final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (pinned != null && renderBox != null) { + final coordinateSystem = CoordinateSystem.instance; + final position = coordinateSystem.screenToCoordinate( + coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ), + ); + ref + .read(placedImageProvider.notifier) + .resize(widget.placedImage.id, scale: localScale!, position: position); + ref.read(strategyProvider.notifier).setUnsaved(); + } + setState(() { + isPanning = false; + pinnedScreenPosition = null; + }); + } + @override Widget build(BuildContext context) { if (localScale == null) { @@ -58,83 +95,85 @@ 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); + 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; - 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 topLeftGlobal = renderBox.localToGlobal(Offset.zero); + final screenZoom = ref.read(screenZoomProvider); + final widthInScreenPixels = + details.globalPosition.dx - topLeftGlobal.dx; + final widthInContentSpace = widthInScreenPixels / screenZoom; + 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; + isPanning = true; + pinnedScreenPosition ??= coordinateSystem.screenPositionForSide( + attackScreenPosition: attackScreenPosition, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ); + localScale = ImageScalePolicy.clamp(widthInWorldSpace); }); }, - 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(), + 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..0d785d75 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,50 @@ 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; @override void initState() { localSize ??= widget.size; super.initState(); } + /// Stores the new width, and 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. + /// + /// Waits for the frame that lays out the last drag update, because the + /// text's height, and so its defense position, follows from its width. + Future _finishResize() async { + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + final pinned = pinnedScreenPosition; + final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (pinned != null && renderBox != null) { + final coordinateSystem = CoordinateSystem.instance; + final position = coordinateSystem.screenToCoordinate( + coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ), + ); + ref + .read(textProvider.notifier) + .resize(widget.placedText.id, size: localSize!, position: position); + ref.read(strategyProvider.notifier).setUnsaved(); + } + setState(() { + isPanning = false; + isDragging = false; + pinnedScreenPosition = null; + }); + } + @override Widget build(BuildContext context) { final texts = ref.watch(textProvider); @@ -65,82 +103,88 @@ 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; + 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; - }); - }, - onDragEnd: (details) { - widget.onDragEnd(details); setState(() { - isDragging = false; + isPanning = true; + pinnedScreenPosition ??= coordinateSystem.screenPositionForSide( + attackScreenPosition: attackScreenPosition, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ); + localSize = widthInWorldSpace.clamp(minSize, double.infinity); }); }, - 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..69f62a20 --- /dev/null +++ b/test/placed_box_resize_test.dart @@ -0,0 +1,189 @@ +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) builder, + 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, _) => Stack(children: [builder(ref)]), + ), + ), + ), + ); + 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', + ]), + builder: (ref) { + 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, + ), + ]), + builder: (ref) { + 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)); + } + }); + } +} 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: (_) {}, ), ], ); From 132388777488296412567c2b73c16dae086b0e48 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 29 Sep 2026 01:10:54 -0400 Subject: [PATCH 2/6] Move the resize pin with a mid-drag side switch Switching sides while holding a resize handle left the pin at the old side's screen position, and release read it on the new side, so the stored position landed hundreds of pixels off. The pin now moves to where the box shows on the new side. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../image/placed_image_builder.dart | 25 +++++++ .../text/placed_text_builder.dart | 25 +++++++ test/placed_box_resize_test.dart | 66 +++++++++++++++++-- 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index 32b4eebe..6361858f 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -47,6 +47,31 @@ 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. + @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); + pinnedScreenPosition = coordinateSystem.screenPositionForSide( + attackScreenPosition: coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: boxSize, + isAttack: oldWidget.isAttack, + ), + reflectionOffset: boxSize, + isAttack: widget.isAttack, + ); + } + /// Stores the new scale, and 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. diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index 0d785d75..b5f7ac07 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -52,6 +52,31 @@ class _PlacedTextBuilderState extends ConsumerState { 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. + @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); + pinnedScreenPosition = coordinateSystem.screenPositionForSide( + attackScreenPosition: coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: boxSize, + isAttack: oldWidget.isAttack, + ), + reflectionOffset: boxSize, + isAttack: widget.isAttack, + ); + } + /// Stores the new width, and 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. diff --git a/test/placed_box_resize_test.dart b/test/placed_box_resize_test.dart index 69f62a20..a17eefcd 100644 --- a/test/placed_box_resize_test.dart +++ b/test/placed_box_resize_test.dart @@ -31,7 +31,8 @@ void main() { Future pumpBox( WidgetTester tester, { - required Widget Function(WidgetRef ref) builder, + required Widget Function(WidgetRef ref, bool isAttack) builder, + required ValueNotifier side, required void Function(ProviderContainer container) seed, }) async { tester.view.physicalSize = playArea; @@ -59,7 +60,11 @@ void main() { container: container, child: ShadApp( home: Consumer( - builder: (context, ref, _) => Stack(children: [builder(ref)]), + builder: (context, ref, _) => ValueListenableBuilder( + valueListenable: side, + builder: (context, isAttack, _) => + Stack(children: [builder(ref, isAttack)]), + ), ), ), ), @@ -105,7 +110,8 @@ void main() { sizeVersion: PlacedText.currentSizeVersion, )..text = 'Yo text boxes resize properly on both sides now', ]), - builder: (ref) { + side: ValueNotifier(isAttack), + builder: (ref, isAttack) { final placedText = ref.watch(textProvider).single; return PlacedTextBuilder( key: ValueKey(placedText.id), @@ -154,7 +160,8 @@ void main() { sizeVersion: worldSizedMediaVersion, ), ]), - builder: (ref) { + side: ValueNotifier(isAttack), + builder: (ref, isAttack) { final placedImage = ref.watch(placedImageProvider).images.single; return PlacedImageBuilder( key: ValueKey(placedImage.id), @@ -186,4 +193,55 @@ void main() { } }); } + + 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(); + 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)); + }); } From bce61365b906736ea56e083c5fa2ffc8cca13373 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 29 Sep 2026 01:23:18 -0400 Subject: [PATCH 3/6] Keep the resize width tracking the pointer after a side switch A side switch mid-resize mirrors the box away from the held pointer, and the next drag update measured the width from the new left edge, so the box jumped hundreds of pixels wide. The builders now remember how far the left edge moved and measure from where it was. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../image/placed_image_builder.dart | 14 ++++++++++++-- .../text/placed_text_builder.dart | 14 ++++++++++++-- test/placed_box_resize_test.dart | 8 ++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index 6361858f..e90467fc 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -41,6 +41,10 @@ class _PlacedImageBuilderState extends State { 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() { super.initState(); @@ -49,6 +53,8 @@ class _PlacedImageBuilderState extends State { /// 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); @@ -61,7 +67,7 @@ class _PlacedImageBuilderState extends State { } final coordinateSystem = CoordinateSystem.instance; final boxSize = renderBox.size.bottomRight(Offset.zero); - pinnedScreenPosition = coordinateSystem.screenPositionForSide( + final nextPinned = coordinateSystem.screenPositionForSide( attackScreenPosition: coordinateSystem.screenPositionFromSide( sideScreenPosition: pinned, reflectionOffset: boxSize, @@ -70,6 +76,8 @@ class _PlacedImageBuilderState extends State { reflectionOffset: boxSize, isAttack: widget.isAttack, ); + pointerShift += nextPinned.dx - pinned.dx; + pinnedScreenPosition = nextPinned; } /// Stores the new scale, and the position that keeps the image's top-left @@ -101,6 +109,7 @@ class _PlacedImageBuilderState extends State { setState(() { isPanning = false; pinnedScreenPosition = null; + pointerShift = 0; }); } @@ -139,7 +148,8 @@ class _PlacedImageBuilderState extends State { final screenZoom = ref.read(screenZoomProvider); final widthInScreenPixels = details.globalPosition.dx - topLeftGlobal.dx; - final widthInContentSpace = widthInScreenPixels / screenZoom; + final widthInContentSpace = + widthInScreenPixels / screenZoom + pointerShift; final widthInWorldSpace = coordinateSystem.screenWidthToWorld(widthInContentSpace); diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index b5f7ac07..ad80fbc1 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -46,6 +46,10 @@ class _PlacedTextBuilderState extends ConsumerState { 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; @@ -54,6 +58,8 @@ class _PlacedTextBuilderState extends ConsumerState { /// 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); @@ -66,7 +72,7 @@ class _PlacedTextBuilderState extends ConsumerState { } final coordinateSystem = CoordinateSystem.instance; final boxSize = renderBox.size.bottomRight(Offset.zero); - pinnedScreenPosition = coordinateSystem.screenPositionForSide( + final nextPinned = coordinateSystem.screenPositionForSide( attackScreenPosition: coordinateSystem.screenPositionFromSide( sideScreenPosition: pinned, reflectionOffset: boxSize, @@ -75,6 +81,8 @@ class _PlacedTextBuilderState extends ConsumerState { reflectionOffset: boxSize, isAttack: widget.isAttack, ); + pointerShift += nextPinned.dx - pinned.dx; + pinnedScreenPosition = nextPinned; } /// Stores the new width, and the position that keeps the box's top-left @@ -107,6 +115,7 @@ class _PlacedTextBuilderState extends ConsumerState { isPanning = false; isDragging = false; pinnedScreenPosition = null; + pointerShift = 0; }); } @@ -147,7 +156,8 @@ class _PlacedTextBuilderState extends ConsumerState { final scale = ref.read(screenZoomProvider); final widthInScreenPixels = details.globalPosition.dx - leftEdgeGlobal.dx; - final widthInContentSpace = widthInScreenPixels / scale; + final widthInContentSpace = + widthInScreenPixels / scale + pointerShift; final widthInWorldSpace = coordinateSystem.screenWidthToWorld(widthInContentSpace); diff --git a/test/placed_box_resize_test.dart b/test/placed_box_resize_test.dart index a17eefcd..72765e83 100644 --- a/test/placed_box_resize_test.dart +++ b/test/placed_box_resize_test.dart @@ -235,6 +235,14 @@ void main() { 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(); From dcf217c4117e64a65cf59c88e928347de4dd4633 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 29 Sep 2026 01:48:05 -0400 Subject: [PATCH 4/6] Store a resize when it is released, not a frame later A resize waited one frame after release before storing, so the final size was laid out. A page switch inside that frame could land the resize on the next page's copy of the item, which keeps the same id. Pointer events arrive between frames, and a frame builds and lays out together. So on release the box on screen always matches the size it was last built at. Storing that size with the rendered box makes the commit synchronous and exact. A final move the frame never drew is dropped, so the box stays where the user last saw it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../image/placed_image_builder.dart | 24 ++++++---- .../text/placed_text_builder.dart | 25 ++++++---- test/placed_box_resize_test.dart | 48 +++++++++++++++++++ 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index e90467fc..a452d759 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -37,6 +37,11 @@ class PlacedImageBuilder extends StatefulWidget { class _PlacedImageBuilderState extends State { final _boxKey = GlobalKey(); double? localScale; // Make localScale nullable to check if it's initialized + + /// The scale the image was last built, and so laid out, at. Pointer events + /// arrive between frames, so the rendered image always matches this scale + /// even when a drag update since has not been drawn yet. + double? shownScale; bool isPanning = false; bool isDragging = false; Offset? pinnedScreenPosition; @@ -80,16 +85,13 @@ class _PlacedImageBuilderState extends State { pinnedScreenPosition = nextPinned; } - /// Stores the new scale, and 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. + /// Stores the scale the user last saw, and 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. /// - /// Waits for the frame that lays out the last drag update, so the stored - /// position matches the size the user let go at. - Future _finishResize(WidgetRef ref) async { - await WidgetsBinding.instance.endOfFrame; - if (!mounted) return; - + /// Stores [shownScale] with the image laid out at it, so the position + /// matches the size on screen rather than a drag update not drawn yet. + void _finishResize(WidgetRef ref) { final pinned = pinnedScreenPosition; final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; if (pinned != null && renderBox != null) { @@ -103,10 +105,11 @@ class _PlacedImageBuilderState extends State { ); ref .read(placedImageProvider.notifier) - .resize(widget.placedImage.id, scale: localScale!, position: position); + .resize(widget.placedImage.id, scale: shownScale!, position: position); ref.read(strategyProvider.notifier).setUnsaved(); } setState(() { + localScale = shownScale; isPanning = false; pinnedScreenPosition = null; pointerShift = 0; @@ -129,6 +132,7 @@ class _PlacedImageBuilderState extends State { ref.read(placedImageProvider).images[index].scale); } + shownScale = localScale; final coordinateSystem = CoordinateSystem.instance; final attackScreenPosition = coordinateSystem.coordinateToScreen(widget.placedImage.position); diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index ad80fbc1..eaec8e12 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -43,6 +43,11 @@ class _PlacedTextBuilderState extends ConsumerState { ]; final _boxKey = GlobalKey(); double? localSize; // Make localScale nullable to check if it's initialized + + /// The width the box was last built, and so laid out, at. Pointer events + /// arrive between frames, so the rendered box always matches this width + /// even when a drag update since has not been drawn yet. + double? shownSize; bool isPanning = false; bool isDragging = false; Offset? pinnedScreenPosition; @@ -85,16 +90,14 @@ class _PlacedTextBuilderState extends ConsumerState { pinnedScreenPosition = nextPinned; } - /// Stores the new width, and 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. + /// Stores the width the user last saw, and 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. /// - /// Waits for the frame that lays out the last drag update, because the - /// text's height, and so its defense position, follows from its width. - Future _finishResize() async { - await WidgetsBinding.instance.endOfFrame; - if (!mounted) return; - + /// The text's height, and so its defense position, follows from its width, + /// so this stores [shownSize] with the box laid out at it rather than a + /// drag update not drawn yet. + void _finishResize() { final pinned = pinnedScreenPosition; final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; if (pinned != null && renderBox != null) { @@ -108,10 +111,11 @@ class _PlacedTextBuilderState extends ConsumerState { ); ref .read(textProvider.notifier) - .resize(widget.placedText.id, size: localSize!, position: position); + .resize(widget.placedText.id, size: shownSize!, position: position); ref.read(strategyProvider.notifier).setUnsaved(); } setState(() { + localSize = shownSize; isPanning = false; isDragging = false; pinnedScreenPosition = null; @@ -137,6 +141,7 @@ class _PlacedTextBuilderState extends ConsumerState { if (texts[index].size != localSize && !isPanning) { localSize = texts[index].size; } + shownSize = localSize; final coordinateSystem = CoordinateSystem.instance; final attackScreenPosition = coordinateSystem.coordinateToScreen(widget.placedText.position); diff --git a/test/placed_box_resize_test.dart b/test/placed_box_resize_test.dart index 72765e83..293096fa 100644 --- a/test/placed_box_resize_test.dart +++ b/test/placed_box_resize_test.dart @@ -252,4 +252,52 @@ void main() { tester.getRect(box).topLeft, offsetMoreOrLessEquals(onDefense.topLeft)); expect(container.read(textProvider).single.size, greaterThan(200)); }); + + testWidgets('release stores what is on screen before the next frame', + (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 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(); + expect(container.read(textProvider).single.size, greaterThan(200)); + + await tester.pumpAndSettle(); + expect(tester.getRect(box).topLeft, offsetMoreOrLessEquals(shown.topLeft)); + expect(tester.getRect(box).width, closeTo(shown.width, 0.01)); + }); } From 658c6afcc84611e2a47824ff5928cb5bb856ca22 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 29 Sep 2026 02:13:31 -0400 Subject: [PATCH 5/6] Keep a quick resize's last movement and write only to its own item Storing the size last drawn at release dropped movement that arrived after the last frame: a quick drag could be lost entirely. Go back to storing the final size one frame after release, when it is laid out. Write it only if the text or image being resized is still loaded. A page switch in that frame loads the next page's copy, which keeps the same id, so the resize can no longer land on it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../image/placed_image_builder.dart | 33 +++++----- .../text/placed_text_builder.dart | 32 +++++----- test/placed_box_resize_test.dart | 61 +++++++++++++++++-- 3 files changed, 90 insertions(+), 36 deletions(-) diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index a452d759..e6390268 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -37,11 +37,6 @@ class PlacedImageBuilder extends StatefulWidget { class _PlacedImageBuilderState extends State { final _boxKey = GlobalKey(); double? localScale; // Make localScale nullable to check if it's initialized - - /// The scale the image was last built, and so laid out, at. Pointer events - /// arrive between frames, so the rendered image always matches this scale - /// even when a drag update since has not been drawn yet. - double? shownScale; bool isPanning = false; bool isDragging = false; Offset? pinnedScreenPosition; @@ -85,16 +80,26 @@ class _PlacedImageBuilderState extends State { pinnedScreenPosition = nextPinned; } - /// Stores the scale the user last saw, and 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. + /// Stores the new scale, and 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. /// - /// Stores [shownScale] with the image laid out at it, so the position - /// matches the size on screen rather than a drag update not drawn yet. - void _finishResize(WidgetRef ref) { + /// Waits for the frame that lays out the last drag update, so the stored + /// position matches the size the user let go at. Writes only if the image + /// it resized is still loaded: a page switch in that frame loads the next + /// page's copy, which keeps the same id. + Future _finishResize(WidgetRef ref) async { + final resizedImage = widget.placedImage; + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final pinned = pinnedScreenPosition; final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; - if (pinned != null && renderBox != null) { + final stillLoaded = ref + .read(placedImageProvider) + .images + .any((image) => identical(image, resizedImage)); + if (pinned != null && renderBox != null && stillLoaded) { final coordinateSystem = CoordinateSystem.instance; final position = coordinateSystem.screenToCoordinate( coordinateSystem.screenPositionFromSide( @@ -105,11 +110,10 @@ class _PlacedImageBuilderState extends State { ); ref .read(placedImageProvider.notifier) - .resize(widget.placedImage.id, scale: shownScale!, position: position); + .resize(widget.placedImage.id, scale: localScale!, position: position); ref.read(strategyProvider.notifier).setUnsaved(); } setState(() { - localScale = shownScale; isPanning = false; pinnedScreenPosition = null; pointerShift = 0; @@ -132,7 +136,6 @@ class _PlacedImageBuilderState extends State { ref.read(placedImageProvider).images[index].scale); } - shownScale = localScale; final coordinateSystem = CoordinateSystem.instance; final attackScreenPosition = coordinateSystem.coordinateToScreen(widget.placedImage.position); diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index eaec8e12..15b164f5 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -43,11 +43,6 @@ class _PlacedTextBuilderState extends ConsumerState { ]; final _boxKey = GlobalKey(); double? localSize; // Make localScale nullable to check if it's initialized - - /// The width the box was last built, and so laid out, at. Pointer events - /// arrive between frames, so the rendered box always matches this width - /// even when a drag update since has not been drawn yet. - double? shownSize; bool isPanning = false; bool isDragging = false; Offset? pinnedScreenPosition; @@ -90,17 +85,24 @@ class _PlacedTextBuilderState extends ConsumerState { pinnedScreenPosition = nextPinned; } - /// Stores the width the user last saw, and 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. + /// Stores the new width, and 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, - /// so this stores [shownSize] with the box laid out at it rather than a - /// drag update not drawn yet. - void _finishResize() { + /// Waits for the frame that lays out the last drag update, because the + /// text's height, and so its defense position, follows from its width. + /// Writes only if the text it resized is still loaded: a page switch in + /// that frame loads the next page's copy, which keeps the same id. + Future _finishResize() async { + final resizedText = widget.placedText; + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final pinned = pinnedScreenPosition; final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; - if (pinned != null && renderBox != null) { + final stillLoaded = + ref.read(textProvider).any((text) => identical(text, resizedText)); + if (pinned != null && renderBox != null && stillLoaded) { final coordinateSystem = CoordinateSystem.instance; final position = coordinateSystem.screenToCoordinate( coordinateSystem.screenPositionFromSide( @@ -111,11 +113,10 @@ class _PlacedTextBuilderState extends ConsumerState { ); ref .read(textProvider.notifier) - .resize(widget.placedText.id, size: shownSize!, position: position); + .resize(widget.placedText.id, size: localSize!, position: position); ref.read(strategyProvider.notifier).setUnsaved(); } setState(() { - localSize = shownSize; isPanning = false; isDragging = false; pinnedScreenPosition = null; @@ -141,7 +142,6 @@ class _PlacedTextBuilderState extends ConsumerState { if (texts[index].size != localSize && !isPanning) { localSize = texts[index].size; } - shownSize = localSize; final coordinateSystem = CoordinateSystem.instance; final attackScreenPosition = coordinateSystem.coordinateToScreen(widget.placedText.position); diff --git a/test/placed_box_resize_test.dart b/test/placed_box_resize_test.dart index 293096fa..8a81b21b 100644 --- a/test/placed_box_resize_test.dart +++ b/test/placed_box_resize_test.dart @@ -253,9 +253,9 @@ void main() { expect(container.read(textProvider).single.size, greaterThan(200)); }); - testWidgets('release stores what is on screen before the next frame', + testWidgets('a release before the next frame keeps its last movement', (tester) async { - final container = await pumpBox( + await pumpBox( tester, seed: (container) => container.read(textProvider.notifier).fromHive([ PlacedText( @@ -294,10 +294,61 @@ void main() { // A last move and the release arrive before the next frame draws it. await gesture.moveBy(const Offset(40, 0)); await gesture.up(); - expect(container.read(textProvider).single.size, greaterThan(200)); - await tester.pumpAndSettle(); + expect(tester.getRect(box).topLeft, offsetMoreOrLessEquals(shown.topLeft)); - expect(tester.getRect(box).width, closeTo(shown.width, 0.01)); + expect(tester.getRect(box).width, closeTo(shown.width + 40, 1)); + }); + + testWidgets('a page switch before the resize is stored leaves the copy alone', + (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(); + } + await gesture.up(); + + // Before the frame, the next page loads its copy of the text. + 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)); }); } From bfe4fabf6e15b1e5cdc29cddcb0ee7daf2ea19ea Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 29 Sep 2026 02:33:39 -0400 Subject: [PATCH 6/6] Store a resize's size on release and settle its position a frame later With the write deferred a frame, a page save right after release still read the old size, and the page switch then replaced the item, so the resize was lost. Store the size, and a position from the current layout, as soon as the handle is released. Hold the pin one more frame, then store the position again if the final layout moved it, only while the same item is still loaded. Normally that second write finds nothing to change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../image/placed_image_builder.dart | 54 ++++++++++-------- .../text/placed_text_builder.dart | 55 +++++++++++-------- test/placed_box_resize_test.dart | 7 ++- 3 files changed, 68 insertions(+), 48 deletions(-) diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index e6390268..c57745c4 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -80,39 +80,26 @@ class _PlacedImageBuilderState extends State { pinnedScreenPosition = nextPinned; } - /// Stores the new scale, and 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. + /// 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. /// - /// Waits for the frame that lays out the last drag update, so the stored - /// position matches the size the user let go at. Writes only if the image - /// it resized is still loaded: a page switch in that frame loads the next - /// page's copy, which keeps the same id. + /// 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 pinned = pinnedScreenPosition; - final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; final stillLoaded = ref .read(placedImageProvider) .images .any((image) => identical(image, resizedImage)); - if (pinned != null && renderBox != null && stillLoaded) { - final coordinateSystem = CoordinateSystem.instance; - final position = coordinateSystem.screenToCoordinate( - coordinateSystem.screenPositionFromSide( - sideScreenPosition: pinned, - reflectionOffset: renderBox.size.bottomRight(Offset.zero), - isAttack: widget.isAttack, - ), - ); - ref - .read(placedImageProvider.notifier) - .resize(widget.placedImage.id, scale: localScale!, position: position); - ref.read(strategyProvider.notifier).setUnsaved(); - } + if (pinned != null && stillLoaded) _storeResize(ref, resizedImage, pinned); setState(() { isPanning = false; pinnedScreenPosition = null; @@ -120,6 +107,27 @@ class _PlacedImageBuilderState extends State { }); } + /// 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) { diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index 15b164f5..4dad542a 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -85,37 +85,25 @@ class _PlacedTextBuilderState extends ConsumerState { pinnedScreenPosition = nextPinned; } - /// Stores the new width, and 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. + /// 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. /// - /// Waits for the frame that lays out the last drag update, because the - /// text's height, and so its defense position, follows from its width. - /// Writes only if the text it resized is still loaded: a page switch in - /// that frame loads the next page's copy, which keeps the same id. + /// 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 pinned = pinnedScreenPosition; - final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; final stillLoaded = ref.read(textProvider).any((text) => identical(text, resizedText)); - if (pinned != null && renderBox != null && stillLoaded) { - final coordinateSystem = CoordinateSystem.instance; - final position = coordinateSystem.screenToCoordinate( - coordinateSystem.screenPositionFromSide( - sideScreenPosition: pinned, - reflectionOffset: renderBox.size.bottomRight(Offset.zero), - isAttack: widget.isAttack, - ), - ); - ref - .read(textProvider.notifier) - .resize(widget.placedText.id, size: localSize!, position: position); - ref.read(strategyProvider.notifier).setUnsaved(); - } + if (pinned != null && stillLoaded) _storeResize(resizedText, pinned); setState(() { isPanning = false; isDragging = false; @@ -124,6 +112,27 @@ class _PlacedTextBuilderState extends ConsumerState { }); } + /// 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); diff --git a/test/placed_box_resize_test.dart b/test/placed_box_resize_test.dart index 8a81b21b..0f2f45a3 100644 --- a/test/placed_box_resize_test.dart +++ b/test/placed_box_resize_test.dart @@ -300,7 +300,7 @@ void main() { expect(tester.getRect(box).width, closeTo(shown.width + 40, 1)); }); - testWidgets('a page switch before the resize is stored leaves the copy alone', + testWidgets('a page switch right after release keeps the resize on its page', (tester) async { final container = await pumpBox( tester, @@ -335,9 +335,12 @@ void main() { await gesture.moveBy(const Offset(20, 0)); await tester.pump(); } + final resized = container.read(textProvider).single; await gesture.up(); - // Before the frame, the next page loads its copy of the text. + // 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),