Parcourir la source

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 <noreply@anthropic.com>
master
Daniel Covington il y a 4 jours
Parent
révision
c98da3ad98
9 fichiers modifiés avec 477 ajouts et 38 suppressions
  1. +1
    -1
      backlog/backlog.md
  2. +34
    -6
      backlog/epics/06_layout_efficiency_and_operator_tooling.md
  3. +2
    -1
      backlog/sprints/sprint-10.md
  4. +109
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/AlignmentCalculator.cs
  5. +12
    -2
      code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
  6. +154
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/AlignmentCalculatorTests.cs
  7. +101
    -27
      code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
  8. +60
    -0
      code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
  9. +4
    -1
      state.md

+ 1
- 1
backlog/backlog.md Voir le fichier

@@ -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) | | 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) | | 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) | | 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) | | 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 ## Notes


+ 34
- 6
backlog/epics/06_layout_efficiency_and_operator_tooling.md Voir le fichier

@@ -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). `TemplateCanvasControl`'s single-selection fields and hit-testing).
**Dependencies:** None. Unblocks "Align and distribute multiple elements" below. **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** **Card**
As a **print operator**, I want to align multiple objects together, so that address blocks and As a **print operator**, I want to align multiple objects together, so that address blocks and
motifs look consistent. motifs look consistent.
@@ -213,13 +241,13 @@ motifs look consistent.
reusing Batch 1's full-form screenshot after adding a new control). reusing Batch 1's full-form screenshot after adding a new control).


**Confirmation (Acceptance Criteria)** **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. 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). 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 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 freshly-captured screenshot, not a reused one from the prerequisite story) — not only a
canvas-only check. canvas-only check.


+ 2
- 1
backlog/sprints/sprint-10.md Voir le fichier

@@ -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) <br> - [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 <br> - [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free) <br> - [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 | | 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) <br> - [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 <br> - [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free) <br> - [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 <br> - [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 <br> - [x] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest <br> - [x] Visible selection indication for every selected item, not just the primary one <br> - [x] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning) <br> - [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) <br> - [x] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core) <br> - [x] Live built-form smoke (touches the properties panel's multi-item behavior) | | 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 <br> - [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 <br> - [x] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest <br> - [x] Visible selection indication for every selected item, not just the primary one <br> - [x] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning) <br> - [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) <br> - [x] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core) <br> - [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) <br> - [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection <br> - [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise <br> - [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free) <br> - [ ] 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) <br> - [x] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection <br> - [x] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise <br> - [x] Unit tests for the alignment/distribution math (Desktop.Core, framework-free) <br> - [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 ## 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. - **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. | | 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. | | 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<TextElementLayout>`) 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. | | 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<TextElementLayout>`) 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. |

+ 109
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/AlignmentCalculator.cs Voir le fichier

@@ -0,0 +1,109 @@
namespace EnvelopeRenderer.Desktop.Core.Design;

/// <summary>
/// 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 — <c>TemplateCanvasControl</c> 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
/// <see cref="CanvasElementEditor.MultiDragTo"/> already uses.
/// </summary>
public static class AlignmentCalculator
{
public enum Edge
{
Left,
Right,
Top,
Bottom,
HorizontalCenter,
VerticalCenter,
}

public enum Axis
{
Horizontal,
Vertical,
}

/// <summary>Computes, for each bounding box in <paramref name="bounds"/>, the (Dx, Dy) delta
/// that moves it to the given <paramref name="edge"/> of the combined bounding box of every
/// item in the selection — e.g. <see cref="Edge.Left"/> 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.</summary>
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;
}

/// <summary>Computes, for each bounding box in <paramref name="bounds"/>, the delta that
/// spaces its center evenly between the centers of the two extreme (first and last, ordered by
/// center position along <paramref name="axis"/>) 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.</summary>
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;
}
}

+ 12
- 2
code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs Voir le fichier

@@ -201,8 +201,7 @@ public sealed class CanvasElementEditor
var result = new List<TextElementLayout>(); var result = new List<TextElementLayout>();
foreach (var element in _document.Elements) 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) if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
{ {
result.Add(element); result.Add(element);
@@ -212,6 +211,17 @@ public sealed class CanvasElementEditor
return result; return result;
} }


/// <summary>Sprint 10, "Align and distribute multiple elements": the same rotation-aware
/// world-space AABB <see cref="ElementsInRect"/> uses for rubber-band hit-testing, exposed
/// publicly so <see cref="TemplateCanvasControl"/> can feed it into
/// <see cref="AlignmentCalculator"/> alongside the parallel Address Control bounds it computes
/// itself.</summary>
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( private static (double MinX, double MinY, double MaxX, double MaxY) WorldBounds(
TextElementLayout element, double width, double height) TextElementLayout element, double width, double height)
{ {


+ 154
- 0
code/src/EnvelopeRenderer.Desktop.Tests/AlignmentCalculatorTests.cs Voir le fichier

@@ -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
}
}

+ 101
- 27
code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs Voir le fichier

@@ -311,6 +311,72 @@ public sealed class TemplateCanvasControl : Control
return false; return false;
} }


/// <summary>Sprint 10, "Align and distribute multiple elements": aligns every item in the
/// current multi-selection to the given <paramref name="edge"/> 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 <see cref="MultiSelectionCount"/> rather than relying on
/// this guard alone, per this story's "clearly disabled, not a silent no-op" AC.</summary>
public void AlignSelection(AlignmentCalculator.Edge edge)
{
if (MultiSelectionCount < 2)
{
return;
}

ApplySelectionDeltas(AlignmentCalculator.ComputeAlignmentDeltas(GetSelectionBounds(out var elements, out var controls), edge), elements, controls);
}

/// <summary>Distributes the current multi-selection with equal center-to-center spacing along
/// the given <paramref name="axis"/> — see <see cref="AlignmentCalculator.ComputeDistributionDeltas"/>
/// 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).</summary>
public void DistributeSelection(AlignmentCalculator.Axis axis)
{
if (MultiSelectionCount < 2)
{
return;
}

ApplySelectionDeltas(AlignmentCalculator.ComputeDistributionDeltas(GetSelectionBounds(out var elements, out var controls), axis), elements, controls);
}

/// <summary>Builds the combined, fixed-order bounding-box list <see cref="AlignmentCalculator"/>
/// 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 <paramref name="elements"/>/
/// <paramref name="controls"/>' matching order.</summary>
private List<(double MinX, double MinY, double MaxX, double MaxY)> GetSelectionBounds(
out List<TextElementLayout> elements, out List<AddressControlLayout> 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<TextElementLayout> elements, List<AddressControlLayout> 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() private (double X, double Y) DefaultNewElementPosition()
{ {
// Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly // 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<AddressControlLayout>(); var result = new List<AddressControlLayout>();
foreach (var control in _document.AddressControls) 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) if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
{ {
result.Add(control); result.Add(control);
@@ -600,6 +640,40 @@ public sealed class TemplateCanvasControl : Control
return result; return result;
} }


/// <summary>Sprint 10, "Align and distribute multiple elements": the same rotation-aware
/// world-space AABB <see cref="AddressControlsInRect"/> already computed inline for rubber-band
/// hit-testing, extracted so alignment/distribution can feed it into
/// <see cref="AlignmentCalculator"/> alongside <see cref="CanvasElementEditor.GetWorldBounds"/>
/// for standalone elements.</summary>
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);
}

/// <summary>Finalizes a rubber-band drag on release: computes the final (normalized) rectangle, /// <summary>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 /// 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, /// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag,


+ 60
- 0
code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs Voir le fichier

@@ -70,6 +70,19 @@ public sealed class TemplateDesignerForm : Form
private readonly Button _deleteSelectedButton = new() { Text = "&Delete Selected", AutoSize = true, Enabled = false }; 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." }; 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). // CSV field mapping (Sprint 3, Batches 2-4).
private readonly Button _loadCsvButton = new() { Text = "Load &CSV...", AutoSize = true }; private readonly Button _loadCsvButton = new() { Text = "Load &CSV...", AutoSize = true };
private readonly Label _csvStatusLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No CSV loaded." }; 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); _selectionLabel.Margin = new Padding(18, 6, 3, 3);
panel.Controls.Add(_selectionLabel); 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; return panel;
} }


/// <summary>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 <see cref="RefreshSelectionLabel"/> already is, so it
/// never drifts out of sync with the canvas's actual selection state.</summary>
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() 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 // Sprint 5: was `selected.IsDynamic ? "{ColumnName}" : StaticText`, which silently showed
// an empty "{}" for any genuinely mixed-content element (ColumnName is null once an // 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 // element has more than one run) — found via live GUI verification against the real


+ 4
- 1
state.md Voir le fichier

@@ -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`. **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 ## 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 | `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 (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 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`. |

Chargement…
Annuler
Enregistrer

Powered by TurnKey Linux.