From c98da3ad986d35bf68048bae12370c1285b9f183 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Tue, 8 Sep 2026 16:45:46 -0400 Subject: [PATCH] Sprint 10 Batch 3: align and distribute multi-selected elements Adds a framework-free AlignmentCalculator (6 alignment edges, center-spaced distribution along 2 axes) operating on world-space bounding boxes, plus a new toolbar row wired to it for the current multi-selection. Completes Sprint 10's committed scope (13/13 points). Co-Authored-By: Claude Sonnet 5 --- backlog/backlog.md | 2 +- ..._layout_efficiency_and_operator_tooling.md | 40 ++++- backlog/sprints/sprint-10.md | 3 +- .../Design/AlignmentCalculator.cs | 109 +++++++++++++ .../Design/CanvasElementEditor.cs | 14 +- .../AlignmentCalculatorTests.cs | 154 ++++++++++++++++++ .../Views/TemplateCanvasControl.cs | 128 ++++++++++++--- .../Views/TemplateDesignerForm.cs | 60 +++++++ state.md | 5 +- 9 files changed, 477 insertions(+), 38 deletions(-) create mode 100644 code/src/EnvelopeRenderer.Desktop.Core/Design/AlignmentCalculator.cs create mode 100644 code/src/EnvelopeRenderer.Desktop.Tests/AlignmentCalculatorTests.cs diff --git a/backlog/backlog.md b/backlog/backlog.md index d3d4a84..f1b5fb5 100644 --- a/backlog/backlog.md +++ b/backlog/backlog.md @@ -10,7 +10,7 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i | 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) | | 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Add a live wrap/clip indicator for text elements" (re-scoped, 3 pts) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) | | 6 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | Done (4 of 4 stories — Sprint 5, 6, 8; whole-control rotation delivered Sprint 8, see Sprint 8 Review outcome below) | -| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (2 of 4 stories Done — "Snap elements to grid and guides" Sprint 7, "Select multiple elements at once on the canvas" Sprint 10 Batch 2; "Align and distribute multiple elements" Ready, committed as Sprint 10 Batch 3, depends on the multi-selection now shipped; "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty) | +| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (3 of 4 stories Done — "Snap elements to grid and guides" Sprint 7, "Select multiple elements at once on the canvas" + "Align and distribute multiple elements" Sprint 10 Batches 2-3; "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty — the epic's last remaining story) | | 8 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started (still blocked behind 2 open impediments; confirmed still non-blocking — both Composite Address Controls and Layout Efficiency still sit ahead of it in this table's order — see Sprint 8 refinement outcome below) | ## Notes diff --git a/backlog/epics/06_layout_efficiency_and_operator_tooling.md b/backlog/epics/06_layout_efficiency_and_operator_tooling.md index 390e043..def974a 100644 --- a/backlog/epics/06_layout_efficiency_and_operator_tooling.md +++ b/backlog/epics/06_layout_efficiency_and_operator_tooling.md @@ -176,7 +176,35 @@ apply the same operation (like alignment) to several elements at once instead of `TemplateCanvasControl`'s single-selection fields and hit-testing). **Dependencies:** None. Unblocks "Align and distribute multiple elements" below. -### Align and distribute multiple elements - Status: Ready +### Align and distribute multiple elements - Status: Done +**Development verification note (Sprint 10 Batch 3, 2026-10-27):** A new framework-free +`AlignmentCalculator` (Desktop.Core) computes pure position deltas from a list of world-space +bounding boxes — `ComputeAlignmentDeltas` (left/right/top/bottom/horizontal-center/vertical-center, +relative to the selection's combined bounding box) and `ComputeDistributionDeltas` (equal +center-to-center spacing along an axis, a documented MVP simplification: center spacing rather than +edge-to-edge gap spacing, simpler to reason about at the cost of not equalizing visible gaps between +differently-sized items — noted as a candidate refinement, not attempted here). `TemplateCanvasControl` +builds the mixed bounding-box list from `CanvasElementEditor.GetWorldBounds` (standalone elements, +exposing the same rotation-aware AABB Batch 2's rubber-band selection already used) and a new +`GetAddressControlWorldBounds` (Address Controls, extracted from Batch 2's `AddressControlsInRect` +to avoid a second copy of the same corner-rotation math), then applies the returned deltas back to +each item as a simple `X += Dx; Y += Dy`. A new toolbar row (`TemplateDesignerForm`: Align +Left/Right/Top/Bottom, Center Horiz./Vert., Distribute Horiz./Vert. — appended to the existing +element toolbar, which wraps to a second line on its own rather than needing a new form layout row) +is enabled only while `TemplateCanvasControl.MultiSelectionCount >= 2`, refreshed from inside the +existing `RefreshSelectionLabel` so it can never drift out of sync with actual selection state — +disabled, not silently a no-op, per this story's own AC. Tests: 12 new `AlignmentCalculatorTests` +(Desktop.Core, framework-free) covering all 6 alignment edges, 3- and 4-item distribution (including +an out-of-position-order case proving items are ranked by current center before being evenly +respaced), the sub-3-item and empty-list no-op cases. Full suite 376/376 -> 388/388. Live-verified +with a reflection-driven harness against the real built form: the toolbar correctly disables with 0 +or 1 selected and enables with 2+; Align Left/Align Top moved a 3-element selection to the expected +shared edge exactly; Distribute Horizontal left the two extreme elements' centers unchanged and +placed the middle element's center at the exact arithmetic midpoint between them; a **fresh** +full-form screenshot (not reused from Batch 2, per the Sprint 7 retrospective carry-in this story's +own conversation notes call out explicitly) confirms the new toolbar row, its enabled state with 3 +items selected, and every selected item's highlight border are all visually correct together. + **Card** As a **print operator**, I want to align multiple objects together, so that address blocks and motifs look consistent. @@ -213,13 +241,13 @@ motifs look consistent. reusing Batch 1's full-form screenshot after adding a new control). **Confirmation (Acceptance Criteria)** -- [ ] The designer supports the alignment operations listed above for the current multi-selection. -- [ ] The designer supports the distribution operations listed above for the current +- [x] The designer supports the alignment operations listed above for the current multi-selection. +- [x] The designer supports the distribution operations listed above for the current multi-selection. -- [ ] Results are reflected immediately on the canvas (no save/reopen required to see the effect). -- [ ] Alignment/distribution operations are disabled or produce no effect when fewer than two +- [x] Results are reflected immediately on the canvas (no save/reopen required to see the effect). +- [x] Alignment/distribution operations are disabled or produce no effect when fewer than two elements are selected (clearly communicated, not a silent no-op). -- [ ] If this story adds any new toolbar/menu/UI affordance to `TemplateDesignerForm` to trigger +- [x] If this story adds any new toolbar/menu/UI affordance to `TemplateDesignerForm` to trigger alignment/distribution, it is verified with an actual **full built-form smoke** (a freshly-captured screenshot, not a reused one from the prerequisite story) — not only a canvas-only check. diff --git a/backlog/sprints/sprint-10.md b/backlog/sprints/sprint-10.md index 8460aa1..369e332 100644 --- a/backlog/sprints/sprint-10.md +++ b/backlog/sprints/sprint-10.md @@ -9,7 +9,7 @@ |---|---|---|---| | Add a live wrap/clip indicator for text elements | 3 points | Done | - [x] Determine, per selected/previewed element with a box, whether the resolved text's natural (unwrapped) GDI+-measured size exceeds the box width (wrapping) and/or the wrapped height exceeds the box height (clipping)
- [x] Draw a small visual indicator (dashed-orange-style, consistent with the existing unmapped-column warning) on `TemplateCanvasControl` and `TemplatePreviewControl` when either condition is true for the current sample record, drawn inside the existing per-element rotation transform so it rotates for free
- [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free)
- [x] Live built-form smoke: a narrow (120pt) boxed element's real canvas screenshot shows both genuine multi-line wrap and the dashed-orange indicator appearing correctly | | Select multiple elements at once on the canvas | 5 points | Done | - [x] Add a multi-select data structure alongside the existing single-selection state (`CanvasElementEditor` for standalone elements, `TemplateCanvasControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected
- [x] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release
- [x] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest
- [x] Visible selection indication for every selected item, not just the primary one
- [x] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning)
- [x] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes)
- [x] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core)
- [x] Live built-form smoke (touches the properties panel's multi-item behavior) | -| Align and distribute multiple elements | 5 points | Not Started | - [ ] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height)
- [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection
- [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise
- [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free)
- [ ] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) | +| Align and distribute multiple elements | 5 points | Done | - [x] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height)
- [x] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection
- [x] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise
- [x] Unit tests for the alignment/distribution math (Desktop.Core, framework-free)
- [x] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) | ## Notes - **Capacity signal:** completed totals across Sprints 1-9 are **20, 19, 18, 18, 15, 18, 18, 13, 13** — nine data points. The 18-20 range remains this team's proven, repeatable velocity; 15 and both 13s are documented, deliberate under-commits (a large/risky story, then two fresh-priority stories), not capacity misses. @@ -36,3 +36,4 @@ Sequenced by dependency and risk: the small, independent indicator story first ( | 1 | 2026-10-27 | **Batch 1** ("Add a live wrap/clip indicator for text elements", 3 points) done, all ACs met. New framework-free `WrapClipDetector` (Desktop.Core) compares an element's natural (unwrapped) GDI+-measured width against its box width, and its GDI+-wrapped height against an explicit clip ceiling when one is set (never flags clipping otherwise, since there is no ceiling to exceed). Wired into `TemplateCanvasControl.DrawElement` and `TemplatePreviewControl.DrawItem`'s existing box-mode branches, drawing a dashed-orange outline (the unmapped-column warning's visual language) inside the same rotation transform every other per-element visual already uses, so it orbits for free when rotated. **Mid-batch, the human product owner raised two live-build issues, addressed together before continuing to Batch 2** (full detail in `epics/02_template_designer_gui_foundation.md`'s and `epics/04_live_preview_and_record_navigation.md`'s post-Sprint-9 correction notes): (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to the Delete/Backspace key and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely, `ResizeDragTo` now only ever sets `Width`, and `TextElementLayout.HasBox`/`TemplateElement.HasBox` were decoupled from `Height` (a new independent `HasHeightClip` flag) as a direct consequence, with `DebenuPdfRenderer.AddPage` gaining a "Width alone" native-wrap-no-clip render path (`DrawWrappedText` unrotated, `GetWrappedText`+`DrawRotatedMultiLineText` rotated). Tests: net +12 (8 obsolete font-scale tests removed, 20 new/updated across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, `TemplateXmlParserTests`, `DebenuPdfRendererWrapTests`, plus the new `WrapClipDetectorTests`). Full suite 476/476 -> 488/488. Live-verified: a real, licensed end-to-end CLI render of a width-only (no height) template showed correct unrotated and rotated auto-height wrap with no clipping; a reflection-driven built-form harness confirmed a real mouse-driven resize drag sets only `Width` (Height/FontSize unchanged), the real Delete key removes the selected element, and a real canvas screenshot shows both genuine wrap and the new indicator on a narrow boxed element. One disclosed evidence-depth caveat: the toolbar Delete button's own `PerformClick()` didn't reliably fire in the synthetic harness (a known WinForms quirk absent a real shown window) — the underlying method was proven correct via a direct call, and the button's wiring matches every other proven-working toolbar button. | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. | | 2 | 2026-10-27 | **Ad-hoc regression fix, before starting Batch 2:** user reported clicking on and moving a dynamic placeholder had become "not smooth any more." Full detail in `epics/02_template_designer_gui_foundation.md`'s new post-Sprint-10-Batch-1 note. Root cause, found by direct `Stopwatch` instrumentation of `TemplateCanvasControl.OnMouseDown` rather than guessing: `SelectionChanged` fired unconditionally on every left-click, including a click on an already-selected element (the normal way to start a drag), and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time that fires — a cost that only became perceptible (~40-60ms/click, measured) once this sprint's added property-panel rows made the refresh heavier. Fixed by only raising `SelectionChanged` when the pre-click and post-click selection identity actually differ. Live-verified with the same instrumented harness: an already-selected element's second click dropped from ~50ms to 0.295ms; a genuine selection change still pays the warranted refresh cost unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix is isolated to the WinForms-only `TemplateCanvasControl`, which has no automated test project). | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. | | 3 | 2026-10-27 | **Batch 2** ("Select multiple elements at once on the canvas", 5 points) done, all ACs met. Built as a strictly additive layer per the story's own conversation notes: `CanvasElementEditor.MultiSelected` (a `HashSet`) alongside the existing `Selected` field, and a parallel `TemplateCanvasControl._multiSelectedAddressControls` for Address Controls, with a `CollapseMultiSelectionIfSingular` rule ensuring every existing single-select code path keeps working unchanged whenever 0 or 1 items end up selected. Rubber-band selection (`CanvasElementEditor.ElementsInRect`, rotation-aware via rotated-corner AABB, plus a parallel `TemplateCanvasControl.AddressControlsInRect`) and modifier-click toggle (`ToggleMultiSelect`, seeding from the current single selection) both replace or extend the multi-selection on release/click; group drag (`BeginMultiDrag`/`MultiDragTo`, snapping the shared *delta* once rather than each item's position independently) moves the whole set together preserving relative offsets; `DrawAddressControl` was split into separate `isPrimarySelected` (handles) and `isMultiSelected` (highlight-only) parameters so every selected item is visibly marked without ambiguous multi-item handles; the properties panel shows "N items selected" and disables per-item editing (Delete disabled too, rather than extended to a bulk operation — out of this story's scope). Tests: +12 (Desktop.Core, including a rotated-element `ElementsInRect` case and a snap-to-grid `MultiDragTo` case). Full suite 364/364 -> 376/376 (Desktop.Core; CLI unaffected at 124/124). Live-verified with a reflection-driven harness against the real built form: rubber-band selected exactly the 2 of 3 elements it should; the panel showed the disabled "2 items selected." state; toggling a third item in and the first back out produced the exact expected membership; a group drag moved two elements by an identical measured delta with their relative offset exactly unchanged and the unselected third element untouched; clicking empty space cleared the selection; a real canvas screenshot confirmed every multi-selected item shows the highlight border. One disclosed evidence-depth caveat: `Control.ModifierKeys` has no public setter and queries live keyboard state, so modifier-click was verified by calling the same private toggle method the click handler delegates to, directly, rather than through a real simulated key-press. One pre-existing, unrelated cosmetic gap found (not fixed) during verification: a plain element's selection highlight border visibly wraps only part of a multi-word display string — confirmed present identically for ordinary single-selection, logged to `logs/technical_debt_log.md` (2026-10-27) rather than fixed in-scope. | Batch 3, "Align and distribute multiple elements" (5 points, epic 6, depends on Batch 2's multi-selection now existing). | None. | +| 4 | 2026-10-27 | **Batch 3** ("Align and distribute multiple elements", 5 points) done, all ACs met — Sprint 10 committed scope now 13/13 points complete. New framework-free `AlignmentCalculator` (Desktop.Core) computes pure position deltas from a list of world-space bounding boxes: `ComputeAlignmentDeltas` (6 edges, relative to the selection's combined bounding box) and `ComputeDistributionDeltas` (equal center-to-center spacing, a documented MVP simplification vs. edge-to-edge gap spacing). `TemplateCanvasControl` feeds it a mixed bounds list built from a newly-public `CanvasElementEditor.GetWorldBounds` (standalone elements, reusing Batch 2's rotation-aware AABB) and a new `GetAddressControlWorldBounds` (Address Controls, extracted from Batch 2's `AddressControlsInRect` to remove a duplicate copy of the corner-rotation math), applying each returned delta as a plain `X += Dx; Y += Dy`. A new toolbar row (8 buttons: 6 alignment edges + 2 distribution axes) was appended to the existing element toolbar — it wraps to a second line on its own, no new form layout row needed — enabled only while 2+ items are multi-selected, refreshed from inside the existing `RefreshSelectionLabel` so it can never drift out of sync. Tests: +12 (`AlignmentCalculatorTests`, Desktop.Core, framework-free, covering all 6 edges, 3- and 4-item distribution including an out-of-order case, and the sub-3/empty no-op cases). Full suite 376/376 -> 388/388. Live-verified with a reflection-driven harness against the real built form: the toolbar correctly disabled at 0 and 1 selected, enabled at 2+; Align Left/Top moved a 3-element selection to the exact expected shared edge; Distribute Horizontal left both extreme elements' centers unchanged and placed the middle element's center at the exact arithmetic midpoint; a **fresh** full-form screenshot (not reused from Batch 2, per this story's own conversation notes) confirmed the new toolbar row, its enabled state, and every selected item's highlight all render correctly together. | Sprint Review and Retrospective (sprint's committed scope is now fully done). | None. | diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/AlignmentCalculator.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/AlignmentCalculator.cs new file mode 100644 index 0000000..ad609fe --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/AlignmentCalculator.cs @@ -0,0 +1,109 @@ +namespace EnvelopeRenderer.Desktop.Core.Design; + +/// +/// Sprint 10, "Align and distribute multiple elements": pure position-delta math for aligning or +/// distributing a multi-selection, operating on plain world-space bounding boxes rather than any +/// concrete element type — TemplateCanvasControl is responsible for computing each selected +/// standalone element's and Address Control's bounding box (mirroring how it already computes +/// bounds for rubber-band selection) and applying the returned delta to whichever kind of item +/// each box came from. Deltas — not absolute positions — are returned so the caller only ever adds +/// an offset to an item's existing (X, Y), the same "move by a shared/derived amount" style +/// already uses. +/// +public static class AlignmentCalculator +{ + public enum Edge + { + Left, + Right, + Top, + Bottom, + HorizontalCenter, + VerticalCenter, + } + + public enum Axis + { + Horizontal, + Vertical, + } + + /// Computes, for each bounding box in , the (Dx, Dy) delta + /// that moves it to the given of the combined bounding box of every + /// item in the selection — e.g. moves every item's left edge to the + /// leftmost of all their current left edges. Returns one delta per input box, in the same + /// order. + public static IReadOnlyList<(double Dx, double Dy)> ComputeAlignmentDeltas( + IReadOnlyList<(double MinX, double MinY, double MaxX, double MaxY)> bounds, Edge edge) + { + if (bounds.Count == 0) + { + return Array.Empty<(double, double)>(); + } + + var left = bounds.Min(b => b.MinX); + var right = bounds.Max(b => b.MaxX); + var top = bounds.Max(b => b.MaxY); + var bottom = bounds.Min(b => b.MinY); + var horizontalCenter = (left + right) / 2.0; + var verticalCenter = (bottom + top) / 2.0; + + var deltas = new (double Dx, double Dy)[bounds.Count]; + for (var i = 0; i < bounds.Count; i++) + { + var b = bounds[i]; + deltas[i] = edge switch + { + Edge.Left => (left - b.MinX, 0.0), + Edge.Right => (right - b.MaxX, 0.0), + Edge.Top => (0.0, top - b.MaxY), + Edge.Bottom => (0.0, bottom - b.MinY), + Edge.HorizontalCenter => (horizontalCenter - ((b.MinX + b.MaxX) / 2.0), 0.0), + Edge.VerticalCenter => (0.0, verticalCenter - ((b.MinY + b.MaxY) / 2.0)), + _ => (0.0, 0.0), + }; + } + + return deltas; + } + + /// Computes, for each bounding box in , the delta that + /// spaces its center evenly between the centers of the two extreme (first and last, ordered by + /// center position along ) items — those two extremes get a zero delta + /// (they anchor the distributed range), and every item between them moves to an evenly-spaced + /// center position. A documented MVP simplification: spacing is computed between item + /// *centers*, not edge-to-edge gaps — center spacing is simpler to reason about and predictable + /// regardless of each item's own size, at the cost of not equalizing visible gaps between + /// differently-sized items (edge-based distribution is a candidate future refinement). Fewer + /// than 3 items has nothing meaningful to distribute (with exactly 2, the "items between the + /// two extremes" set is empty) and returns all-zero deltas rather than moving anything. + public static IReadOnlyList<(double Dx, double Dy)> ComputeDistributionDeltas( + IReadOnlyList<(double MinX, double MinY, double MaxX, double MaxY)> bounds, Axis axis) + { + var count = bounds.Count; + var deltas = new (double Dx, double Dy)[count]; + if (count < 3) + { + return deltas; + } + + double Center(int i) => axis == Axis.Horizontal + ? (bounds[i].MinX + bounds[i].MaxX) / 2.0 + : (bounds[i].MinY + bounds[i].MaxY) / 2.0; + + var order = Enumerable.Range(0, count).OrderBy(Center).ToArray(); + var firstCenter = Center(order[0]); + var lastCenter = Center(order[^1]); + var step = (lastCenter - firstCenter) / (count - 1); + + for (var rank = 0; rank < count; rank++) + { + var index = order[rank]; + var targetCenter = firstCenter + (step * rank); + var delta = targetCenter - Center(index); + deltas[index] = axis == Axis.Horizontal ? (delta, 0.0) : (0.0, delta); + } + + return deltas; + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs index e3b26c9..69cacaa 100644 --- a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs +++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs @@ -201,8 +201,7 @@ public sealed class CanvasElementEditor var result = new List(); foreach (var element in _document.Elements) { - var (width, height) = _measureText(element); - var (elMinX, elMinY, elMaxX, elMaxY) = WorldBounds(element, width, height); + var (elMinX, elMinY, elMaxX, elMaxY) = GetWorldBounds(element); if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY) { result.Add(element); @@ -212,6 +211,17 @@ public sealed class CanvasElementEditor return result; } + /// Sprint 10, "Align and distribute multiple elements": the same rotation-aware + /// world-space AABB uses for rubber-band hit-testing, exposed + /// publicly so can feed it into + /// alongside the parallel Address Control bounds it computes + /// itself. + public (double MinX, double MinY, double MaxX, double MaxY) GetWorldBounds(TextElementLayout element) + { + var (width, height) = _measureText(element); + return WorldBounds(element, width, height); + } + private static (double MinX, double MinY, double MaxX, double MaxY) WorldBounds( TextElementLayout element, double width, double height) { diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/AlignmentCalculatorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/AlignmentCalculatorTests.cs new file mode 100644 index 0000000..22d56cf --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/AlignmentCalculatorTests.cs @@ -0,0 +1,154 @@ +using EnvelopeRenderer.Desktop.Core.Design; + +namespace EnvelopeRenderer.Desktop.Tests; + +public class AlignmentCalculatorTests +{ + // Three boxes at varying positions/sizes, reused across several tests: + // A: (0,0)-(10,10) B: (50,20)-(70,30) C: (100,-10)-(110,0) + private static readonly (double MinX, double MinY, double MaxX, double MaxY)[] ThreeBoxes = + { + (0, 0, 10, 10), + (50, 20, 70, 30), + (100, -10, 110, 0), + }; + + [Fact] + public void ComputeAlignmentDeltas_Left_MovesEveryBoxToTheLeftmostMinX() + { + var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.Left); + + Assert.Equal(0.0, deltas[0].Dx, precision: 6); // A is already leftmost (MinX=0) + Assert.Equal(-50.0, deltas[1].Dx, precision: 6); // B's MinX=50 -> 0 + Assert.Equal(-100.0, deltas[2].Dx, precision: 6); // C's MinX=100 -> 0 + Assert.All(deltas, d => Assert.Equal(0.0, d.Dy, precision: 6)); + } + + [Fact] + public void ComputeAlignmentDeltas_Right_MovesEveryBoxToTheRightmostMaxX() + { + var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.Right); + + // Rightmost MaxX across all three is 110 (box C). + Assert.Equal(100.0, deltas[0].Dx, precision: 6); // A's MaxX=10 -> 110 + Assert.Equal(40.0, deltas[1].Dx, precision: 6); // B's MaxX=70 -> 110 + Assert.Equal(0.0, deltas[2].Dx, precision: 6); // C already rightmost + } + + [Fact] + public void ComputeAlignmentDeltas_Top_MovesEveryBoxToTheTopmostMaxY() + { + var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.Top); + + // Topmost MaxY across all three is 30 (box B). + Assert.Equal(20.0, deltas[0].Dy, precision: 6); // A's MaxY=10 -> 30 + Assert.Equal(0.0, deltas[1].Dy, precision: 6); // B already topmost + Assert.Equal(30.0, deltas[2].Dy, precision: 6); // C's MaxY=0 -> 30 + Assert.All(deltas, d => Assert.Equal(0.0, d.Dx, precision: 6)); + } + + [Fact] + public void ComputeAlignmentDeltas_Bottom_MovesEveryBoxToTheBottommostMinY() + { + var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.Bottom); + + // Bottommost MinY across all three is -10 (box C). + Assert.Equal(-10.0, deltas[0].Dy, precision: 6); // A's MinY=0 -> -10 + Assert.Equal(-30.0, deltas[1].Dy, precision: 6); // B's MinY=20 -> -10 + Assert.Equal(0.0, deltas[2].Dy, precision: 6); // C already bottommost + } + + [Fact] + public void ComputeAlignmentDeltas_HorizontalCenter_MovesEveryBoxToTheOverallHorizontalCenter() + { + // Overall bounds: MinX=0 (A), MaxX=110 (C) -> horizontal center = 55. + var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.HorizontalCenter); + + Assert.Equal(50.0, deltas[0].Dx, precision: 6); // A's center=5 -> 55 + Assert.Equal(-5.0, deltas[1].Dx, precision: 6); // B's center=60 -> 55 + Assert.Equal(-50.0, deltas[2].Dx, precision: 6); // C's center=105 -> 55 + } + + [Fact] + public void ComputeAlignmentDeltas_VerticalCenter_MovesEveryBoxToTheOverallVerticalCenter() + { + // Overall bounds: MinY=-10 (C), MaxY=30 (B) -> vertical center = 10. + var deltas = AlignmentCalculator.ComputeAlignmentDeltas(ThreeBoxes, AlignmentCalculator.Edge.VerticalCenter); + + Assert.Equal(5.0, deltas[0].Dy, precision: 6); // A's center=5 -> 10 + Assert.Equal(-15.0, deltas[1].Dy, precision: 6); // B's center=25 -> 10 + Assert.Equal(15.0, deltas[2].Dy, precision: 6); // C's center=-5 -> 10 + } + + [Fact] + public void ComputeAlignmentDeltas_EmptyList_ReturnsEmpty() + { + var deltas = AlignmentCalculator.ComputeAlignmentDeltas(Array.Empty<(double, double, double, double)>(), AlignmentCalculator.Edge.Left); + + Assert.Empty(deltas); + } + + [Fact] + public void ComputeDistributionDeltas_ThreeItems_KeepsExtremesFixedAndCentersTheMiddleOne() + { + // Centers along X: A=5, B=60, C=105. Ordered: A(5), B(60), C(105). + // Evenly spaced target centers between the extremes (5 and 105): 5, 55, 105. + var deltas = AlignmentCalculator.ComputeDistributionDeltas(ThreeBoxes, AlignmentCalculator.Axis.Horizontal); + + Assert.Equal(0.0, deltas[0].Dx, precision: 6); // A (leftmost extreme) stays fixed + Assert.Equal(-5.0, deltas[1].Dx, precision: 6); // B's center 60 -> 55 + Assert.Equal(0.0, deltas[2].Dx, precision: 6); // C (rightmost extreme) stays fixed + Assert.All(deltas, d => Assert.Equal(0.0, d.Dy, precision: 6)); + } + + [Fact] + public void ComputeDistributionDeltas_Vertical_UsesYCentersInstead() + { + // Centers along Y: A=5, B=25, C=-5. Ordered by Y center: C(-5), A(5), B(25). + // Evenly spaced target centers between the extremes (-5 and 25): -5, 10, 25. + var deltas = AlignmentCalculator.ComputeDistributionDeltas(ThreeBoxes, AlignmentCalculator.Axis.Vertical); + + Assert.Equal(5.0, deltas[0].Dy, precision: 6); // A's center 5 -> 10 (middle rank) + Assert.Equal(0.0, deltas[1].Dy, precision: 6); // B (topmost extreme) stays fixed + Assert.Equal(0.0, deltas[2].Dy, precision: 6); // C (bottommost extreme) stays fixed + } + + [Fact] + public void ComputeDistributionDeltas_FewerThanThreeItems_ReturnsAllZeroDeltas() + { + var twoBoxes = new[] { ThreeBoxes[0], ThreeBoxes[2] }; + + var deltas = AlignmentCalculator.ComputeDistributionDeltas(twoBoxes, AlignmentCalculator.Axis.Horizontal); + + Assert.All(deltas, d => Assert.Equal((0.0, 0.0), d)); + } + + [Fact] + public void ComputeDistributionDeltas_EmptyList_ReturnsEmpty() + { + var deltas = AlignmentCalculator.ComputeDistributionDeltas(Array.Empty<(double, double, double, double)>(), AlignmentCalculator.Axis.Horizontal); + + Assert.Empty(deltas); + } + + [Fact] + public void ComputeDistributionDeltas_FourItems_SpacesTheTwoMiddleOnesEvenly() + { + var boxes = new (double MinX, double MinY, double MaxX, double MaxY)[] + { + (0, 0, 2, 2), // center X = 1 + (10, 0, 12, 2), // center X = 11 (out of order on purpose) + (30, 0, 32, 2), // center X = 31 (rightmost extreme) + (5, 0, 7, 2), // center X = 6 + }; + + var deltas = AlignmentCalculator.ComputeDistributionDeltas(boxes, AlignmentCalculator.Axis.Horizontal); + + // Order by center: box0(1), box3(6), box1(11), box2(31). Step = (31-1)/3 = 10. + // Target centers by rank: 1, 11, 21, 31. + Assert.Equal(0.0, deltas[0].Dx, precision: 6); // box0 stays at center 1 + Assert.Equal(10.0, deltas[1].Dx, precision: 6); // box1's center 11 -> 21 + Assert.Equal(0.0, deltas[2].Dx, precision: 6); // box2 (rightmost extreme) stays fixed + Assert.Equal(5.0, deltas[3].Dx, precision: 6); // box3's center 6 -> 11 + } +} diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs index 0796853..a258e8b 100644 --- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs +++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs @@ -311,6 +311,72 @@ public sealed class TemplateCanvasControl : Control return false; } + /// Sprint 10, "Align and distribute multiple elements": aligns every item in the + /// current multi-selection to the given of the selection's combined + /// bounding box. A no-op below 2 selected items — callers (the alignment toolbar buttons) are + /// expected to disable themselves via rather than relying on + /// this guard alone, per this story's "clearly disabled, not a silent no-op" AC. + public void AlignSelection(AlignmentCalculator.Edge edge) + { + if (MultiSelectionCount < 2) + { + return; + } + + ApplySelectionDeltas(AlignmentCalculator.ComputeAlignmentDeltas(GetSelectionBounds(out var elements, out var controls), edge), elements, controls); + } + + /// Distributes the current multi-selection with equal center-to-center spacing along + /// the given — see + /// for the documented MVP center-spacing simplification. A no-op below 2 selected items (and, + /// per that method's own remarks, has no visible effect below 3, since there is nothing to + /// place between the two extreme items). + public void DistributeSelection(AlignmentCalculator.Axis axis) + { + if (MultiSelectionCount < 2) + { + return; + } + + ApplySelectionDeltas(AlignmentCalculator.ComputeDistributionDeltas(GetSelectionBounds(out var elements, out var controls), axis), elements, controls); + } + + /// Builds the combined, fixed-order bounding-box list + /// operates on — standalone elements first, then Address Controls — so a returned delta list + /// can be zipped back to the exact item it applies to via / + /// ' matching order. + private List<(double MinX, double MinY, double MaxX, double MaxY)> GetSelectionBounds( + out List elements, out List controls) + { + elements = _editor.MultiSelected.ToList(); + controls = _multiSelectedAddressControls.ToList(); + + var bounds = new List<(double MinX, double MinY, double MaxX, double MaxY)>(elements.Count + controls.Count); + bounds.AddRange(elements.Select(_editor.GetWorldBounds)); + bounds.AddRange(controls.Select(GetAddressControlWorldBounds)); + return bounds; + } + + private void ApplySelectionDeltas( + IReadOnlyList<(double Dx, double Dy)> deltas, List elements, List controls) + { + for (var i = 0; i < elements.Count; i++) + { + elements[i].X += deltas[i].Dx; + elements[i].Y += deltas[i].Dy; + } + + for (var i = 0; i < controls.Count; i++) + { + var delta = deltas[elements.Count + i]; + controls[i].X += delta.Dx; + controls[i].Y += delta.Dy; + } + + Invalidate(); + ElementsChanged?.Invoke(this, EventArgs.Empty); + } + private (double X, double Y) DefaultNewElementPosition() { // Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly @@ -564,33 +630,7 @@ public sealed class TemplateCanvasControl : Control var result = new List(); foreach (var control in _document.AddressControls) { - var top = control.Y + MaxLineFontSize(control); - var bottom = control.Y - control.Height; - var left = control.X; - var right = control.X + control.Width; - - double elMinX, elMinY, elMaxX, elMaxY; - if (control.RotationAngle == 0) - { - (elMinX, elMinY, elMaxX, elMaxY) = (left, bottom, right, top); - } - else - { - var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) }; - elMinX = double.MaxValue; - elMinY = double.MaxValue; - elMaxX = double.MinValue; - elMaxY = double.MinValue; - foreach (var (cornerX, cornerY) in corners) - { - var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle); - elMinX = Math.Min(elMinX, rx); - elMaxX = Math.Max(elMaxX, rx); - elMinY = Math.Min(elMinY, ry); - elMaxY = Math.Max(elMaxY, ry); - } - } - + var (elMinX, elMinY, elMaxX, elMaxY) = GetAddressControlWorldBounds(control); if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY) { result.Add(control); @@ -600,6 +640,40 @@ public sealed class TemplateCanvasControl : Control return result; } + /// Sprint 10, "Align and distribute multiple elements": the same rotation-aware + /// world-space AABB already computed inline for rubber-band + /// hit-testing, extracted so alignment/distribution can feed it into + /// alongside + /// for standalone elements. + private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control) + { + var top = control.Y + MaxLineFontSize(control); + var bottom = control.Y - control.Height; + var left = control.X; + var right = control.X + control.Width; + + if (control.RotationAngle == 0) + { + return (left, bottom, right, top); + } + + var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) }; + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; + foreach (var (cornerX, cornerY) in corners) + { + var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle); + minX = Math.Min(minX, rx); + maxX = Math.Max(maxX, rx); + minY = Math.Min(minY, ry); + maxY = Math.Max(maxY, ry); + } + + return (minX, minY, maxX, maxY); + } + /// Finalizes a rubber-band drag on release: computes the final (normalized) rectangle, /// finds every element/control it intersects, and either replaces the multi-selection with that /// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag, diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs index 4d50bd4..b8efbde 100644 --- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs +++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs @@ -70,6 +70,19 @@ public sealed class TemplateDesignerForm : Form private readonly Button _deleteSelectedButton = new() { Text = "&Delete Selected", AutoSize = true, Enabled = false }; private readonly Label _selectionLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No element selected." }; + // Sprint 10, "Align and distribute multiple elements": enabled only while 2+ items are + // multi-selected (see RefreshAlignmentButtonsEnabled) — disabled, not silently a no-op, per + // this story's own AC, matching the same "clearly disabled" treatment the rest of this + // toolbar already gives every selection-dependent control. + private readonly Button _alignLeftButton = new() { Text = "Align Left", AutoSize = true, Enabled = false }; + private readonly Button _alignRightButton = new() { Text = "Align Right", AutoSize = true, Enabled = false }; + private readonly Button _alignTopButton = new() { Text = "Align Top", AutoSize = true, Enabled = false }; + private readonly Button _alignBottomButton = new() { Text = "Align Bottom", AutoSize = true, Enabled = false }; + private readonly Button _alignCenterHButton = new() { Text = "Center Horiz.", AutoSize = true, Enabled = false }; + private readonly Button _alignCenterVButton = new() { Text = "Center Vert.", AutoSize = true, Enabled = false }; + private readonly Button _distributeHButton = new() { Text = "Distribute Horiz.", AutoSize = true, Enabled = false }; + private readonly Button _distributeVButton = new() { Text = "Distribute Vert.", AutoSize = true, Enabled = false }; + // CSV field mapping (Sprint 3, Batches 2-4). private readonly Button _loadCsvButton = new() { Text = "Load &CSV...", AutoSize = true }; private readonly Label _csvStatusLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No CSV loaded." }; @@ -1217,11 +1230,58 @@ public sealed class TemplateDesignerForm : Form _selectionLabel.Margin = new Padding(18, 6, 3, 3); panel.Controls.Add(_selectionLabel); + // Sprint 10, "Align and distribute multiple elements": a new button group, enabled only + // while 2+ items are multi-selected (RefreshAlignmentButtonsEnabled, called from every + // place selection state already gets refreshed). Appended to this same toolbar rather than + // a new TableLayoutPanel row — FlowLayoutPanel wraps to a second line on its own once it + // runs out of horizontal space, so no layout restructuring is needed. + _alignLeftButton.Margin = new Padding(18, 3, 3, 3); + _alignLeftButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.Left); + _alignRightButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.Right); + _alignTopButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.Top); + _alignBottomButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.Bottom); + _alignCenterHButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.HorizontalCenter); + _alignCenterVButton.Click += (_, _) => _canvas.AlignSelection(AlignmentCalculator.Edge.VerticalCenter); + _distributeHButton.Margin = new Padding(12, 3, 3, 3); + _distributeHButton.Click += (_, _) => _canvas.DistributeSelection(AlignmentCalculator.Axis.Horizontal); + _distributeVButton.Click += (_, _) => _canvas.DistributeSelection(AlignmentCalculator.Axis.Vertical); + + panel.Controls.Add(_alignLeftButton); + panel.Controls.Add(_alignRightButton); + panel.Controls.Add(_alignTopButton); + panel.Controls.Add(_alignBottomButton); + panel.Controls.Add(_alignCenterHButton); + panel.Controls.Add(_alignCenterVButton); + panel.Controls.Add(_distributeHButton); + panel.Controls.Add(_distributeVButton); + return panel; } + /// Sprint 10, "Align and distribute multiple elements": the align/distribute buttons + /// are enabled only while 2+ items are multi-selected — disabled, not silently a no-op, per + /// this story's own AC. Called everywhere already is, so it + /// never drifts out of sync with the canvas's actual selection state. + private void RefreshAlignmentButtonsEnabled() + { + var enabled = _canvas.MultiSelectionCount >= 2; + _alignLeftButton.Enabled = enabled; + _alignRightButton.Enabled = enabled; + _alignTopButton.Enabled = enabled; + _alignBottomButton.Enabled = enabled; + _alignCenterHButton.Enabled = enabled; + _alignCenterVButton.Enabled = enabled; + _distributeHButton.Enabled = enabled; + _distributeVButton.Enabled = enabled; + } + private void RefreshSelectionLabel() { + // Sprint 10: kept in lock-step with this method rather than added at every one of its own + // call sites — every place selection state can change already calls RefreshSelectionLabel, + // so piggybacking here means the alignment buttons can never drift out of sync with it. + RefreshAlignmentButtonsEnabled(); + // Sprint 5: was `selected.IsDynamic ? "{ColumnName}" : StaticText`, which silently showed // an empty "{}" for any genuinely mixed-content element (ColumnName is null once an // element has more than one run) — found via live GUI verification against the real diff --git a/state.md b/state.md index 4ca046c..bf6ca96 100644 --- a/state.md +++ b/state.md @@ -22,7 +22,9 @@ **Sprint 10 Batch 2 complete (2026-10-27):** "Select multiple elements at once on the canvas" (5/13 points) shipped — a strictly additive multi-select layer (`CanvasElementEditor.MultiSelected` for standalone elements, `TemplateCanvasControl._multiSelectedAddressControls` for Address Controls) alongside the existing single-selection fields, collapsing back to ordinary single-select whenever 0-1 items end up selected so no existing code path changed behavior. Rubber-band selection and modifier-click toggle both work across a mixed selection of elements and Address Controls; group drag moves the whole set by one shared delta, preserving relative offsets; every selected item is visibly highlighted; the properties panel shows "N items selected" and disables per-item editing. Full suite 364/364 -> 376/376 (Desktop.Core; CLI unaffected). Live-verified via a reflection-driven built-form harness (rubber-band, toggle, group-drag delta math, panel state, a real canvas screenshot) with one disclosed caveat (`Control.ModifierKeys` has no public setter, so modifier-click was verified by calling the same private toggle method directly rather than through a simulated key-press) and one pre-existing, unrelated cosmetic gap found and logged rather than fixed in-scope (a selection highlight border under-wraps a multi-word display string — `logs/technical_debt_log.md`, 2026-10-27). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`. -**Next action:** Continue Sprint 10 as `dev-team`: Batch 3, "Align and distribute multiple elements" (5 pts, epic 6, depends on Batch 2's multi-selection now existing), per `backlog/sprints/sprint-10.md`. This completes the sprint's committed scope (13/13 points) once done — plan for Sprint Review and Retrospective to follow immediately after, per the standard state-driven handoff. Still open, non-blocking: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred to Sprint 11); "Undo and redo layout changes" (epic 6, 13 pts); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision; two technical debt items from Batch 2's verification (Address-Control interaction logic still untested WinForms-only, and the selection-highlight width cosmetic gap), both Open/Low and non-blocking. +**Sprint 10 Batch 3 complete (2026-10-27):** "Align and distribute multiple elements" (5/13 points) shipped — Sprint 10's full committed scope (13/13 points) is now Done. New framework-free `AlignmentCalculator` (Desktop.Core) computes position deltas for 6 alignment edges and 2 distribution axes (center-to-center spacing, a documented MVP simplification) from a list of world-space bounding boxes; `TemplateCanvasControl` feeds it a mixed bounds list (standalone elements via a newly-public `CanvasElementEditor.GetWorldBounds`, Address Controls via a new `GetAddressControlWorldBounds` extracted from Batch 2's rubber-band code) and applies the deltas back. A new 8-button toolbar row triggers the operations, enabled only at 2+ selected items. Full suite 376/376 -> 388/388. Live-verified via reflection-driven harness including a **fresh** full-form screenshot (not reused from Batch 2, per this story's own carry-in note). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`. + +**Next action:** Sprint 10's committed scope is fully done (13/13 points, all 3 batches). Per the automated state-driven handoff, proceed directly into Sprint Review (`product-owner` leads, `process/04_sprint_review.md`) and then Sprint Retrospective (`scrum-master` leads, `process/05_sprint_retrospective.md`) without stopping to ask — the retrospective's own completion is this workflow's next designated pause point (per `AGENTS.md`), not before. Still open, non-blocking, to consider for Sprint 11 planning: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred from Sprint 10); "Undo and redo layout changes" (epic 6, 13 pts, now the last story in this epic); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision; two technical debt items from Sprint 10's verification (Address-Control interaction logic still untested WinForms-only, growing with two features now built on it; the selection-highlight width cosmetic gap), both Open/Low and non-blocking. ## Phase reference @@ -98,3 +100,4 @@ After phase 5, loop back to phase 1 for the next sprint. | 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 1, "Add a live wrap/clip indicator for text elements" (3/13 points). New framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel. Mid-batch, the human product owner raised two live-build issues, addressed together: (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely; `HasBox` now means "Width is set" alone, `Height` independent and optional (new `HasHeightClip`), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path. Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) confirmed auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`. | | 2026-10-27 | 3 - Sprint execution (ad hoc, outside formal ceremony) | User reported that clicking on and moving a dynamic placeholder had become "not smooth any more." Root cause found by directly instrumenting `TemplateCanvasControl.OnMouseDown` with `Stopwatch` timers rather than guessing: `SelectionChanged` fired unconditionally on every left-click — including a click on an already-selected element, the normal way to begin dragging it — and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time, a cost (~40-60ms/click, measured) that only became perceptible once this sprint's added property-panel rows made the refresh heavier. Fixed by capturing the pre-click selection identity and only raising `SelectionChanged` when it actually changed (`RaiseSelectionChangedIfDifferent`). Live-verified with the same instrumented reflection harness: an already-selected element's second click dropped from ~50ms to 0.295ms end-to-end in `OnMouseDown`, while a genuine selection change still correctly pays the warranted refresh cost unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix isolated to the WinForms-only `TemplateCanvasControl`, which has no automated test project). Documented in `backlog/epics/02_template_designer_gui_foundation.md` and `backlog/sprints/sprint-10.md`. | | 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 2, "Select multiple elements at once on the canvas" (5/13 points). Additive multi-select layer alongside existing single-selection state (`CanvasElementEditor.MultiSelected`, `TemplateCanvasControl._multiSelectedAddressControls`), collapsing to ordinary single-select at 0-1 items so every existing code path is unaffected. Rubber-band selection (rotation-aware AABB intersection) and modifier-click toggle both work across mixed elements/Address Controls; group drag moves the set by one shared delta preserving relative offsets (snapping the delta once, not each item's position independently); every selected item is visibly highlighted; properties panel shows "N items selected," disabled for per-item editing. 12 new Desktop.Core tests, full suite 364/364 -> 376/376. Live-verified via reflection-driven built-form harness including a real canvas screenshot; one disclosed caveat (`ModifierKeys` has no public setter, so modifier-click verified via direct private-method call) and one unrelated pre-existing cosmetic gap found and logged, not fixed in-scope (selection-highlight width under-wrap). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`. | +| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 3, "Align and distribute multiple elements" (5/13 points) — sprint's full committed scope (13/13) now Done. New framework-free `AlignmentCalculator` (Desktop.Core) computes deltas for 6 alignment edges and center-spaced distribution along 2 axes from world-space bounding boxes; `TemplateCanvasControl` feeds it a mixed elements/Address-Controls bounds list (reusing/extracting Batch 2's rotation-aware AABB code) and applies deltas back as plain position offsets. New 8-button toolbar row enabled only at 2+ selected items, refreshed in lock-step with existing selection-state refresh so it can't drift out of sync. 12 new Desktop.Core tests, full suite 376/376 -> 388/388. Live-verified via reflection-driven harness: exact expected alignment/distribution results, correct button enable/disable at 0/1/2+ selected, and a fresh full-form screenshot (not reused from Batch 2, per this story's own carry-in note) confirming the new toolbar and highlights render correctly together. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`. |