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
| @@ -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 | ||||
| @@ -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. | ||||
| @@ -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. | | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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) | ||||
| { | { | ||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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, | ||||
| @@ -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 | ||||
| @@ -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`. | | |||||
Powered by TurnKey Linux.