diff --git a/backlog/backlog.md b/backlog/backlog.md
index 7c74b45..d3d4a84 100644
--- a/backlog/backlog.md
+++ b/backlog/backlog.md
@@ -10,7 +10,7 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
| 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) |
| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Add a live wrap/clip indicator for text elements" (re-scoped, 3 pts) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) |
| 6 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | Done (4 of 4 stories — Sprint 5, 6, 8; whole-control rotation delivered Sprint 8, see Sprint 8 Review outcome below) |
-| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (1 of 4 stories Done — Sprint 7; "Select multiple elements at once on the canvas" and "Align and distribute multiple elements" Ready as a dependent pair, "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 (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) |
| 8 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started (still blocked behind 2 open impediments; confirmed still non-blocking — both Composite Address Controls and Layout Efficiency still sit ahead of it in this table's order — see Sprint 8 refinement outcome below) |
## Notes
diff --git a/backlog/epics/06_layout_efficiency_and_operator_tooling.md b/backlog/epics/06_layout_efficiency_and_operator_tooling.md
index 1cdcc91..390e043 100644
--- a/backlog/epics/06_layout_efficiency_and_operator_tooling.md
+++ b/backlog/epics/06_layout_efficiency_and_operator_tooling.md
@@ -88,7 +88,63 @@ professional layouts quickly.
editor/canvas pair; no cross-layer (CLI/XML) work needed.
**Dependencies:** None.
-### Select multiple elements at once on the canvas - Status: Ready
+### Select multiple elements at once on the canvas - Status: Done
+**Development verification note (Sprint 10 Batch 2, 2026-10-27):** Implemented as a strictly
+additive layer, exactly per this story's own conversation notes: `CanvasElementEditor` gained a
+`MultiSelected` set (standalone elements) alongside its existing `Selected` field, and
+`TemplateCanvasControl` gained a parallel `_multiSelectedAddressControls` set — every existing
+single-select code path (properties panel, rotate/resize handles, address-line drill-in) was
+verified unchanged when 0 or 1 items end up selected, via a `CollapseMultiSelectionIfSingular` rule
+applied after every multi-selection mutation.
+
+- **Rubber-band selection:** a click-drag starting on empty canvas space (checked only after every
+ handle/element/Address-Control hit-test already misses) draws a marquee and, on release, selects
+ every standalone element and Address Control whose world-space (rotation-aware) axis-aligned
+ bounding box intersects it — `CanvasElementEditor.ElementsInRect` (framework-free, unit tested,
+ including a rotated-element case proving the rotated corners, not the unrotated box, drive the
+ test) and a parallel `TemplateCanvasControl.AddressControlsInRect` (WinForms-only, same
+ intentional test-coverage split this epic's own 2026-10-26 technical debt entry already
+ documents).
+- **Modifier-click (Ctrl/Shift):** toggles one item's membership without disturbing the rest,
+ seeding the multi-selection from whatever was singly selected first if nothing was multi-selected
+ yet (the standard "extend the current selection" UX) — `CanvasElementEditor.ToggleMultiSelect`
+ (unit tested) plus `TemplateCanvasControl`'s parallel Address Control toggle.
+- **Visible indication for every selected item:** `DrawElement`'s existing highlight condition was
+ extended to include multi-selected membership; `DrawAddressControl`/`DrawAddressControlUnrotated`
+ were split into separate `isPrimarySelected` (drives resize/rotate handles) and `isMultiSelected`
+ (drives only the highlight border) parameters, so a multi-selected Address Control is visibly
+ marked without showing handles that would be ambiguous about which item they'd act on.
+- **Group drag preserving relative offsets:** `CanvasElementEditor.BeginMultiDrag`/`MultiDragTo`
+ (unit tested, including a snap-to-grid case proving the *delta* is snapped once rather than each
+ item's absolute position independently — the latter would have pulled unevenly-grid-aligned items
+ closer together or further apart) plus a parallel `TemplateCanvasControl.ApplyMultiDragToAddressControls`
+ applying the identical shared delta to the Address Control half every tick.
+- **Properties panel:** `TemplateDesignerForm.RefreshPropertiesPanel`/`RefreshSelectionLabel` check
+ a new `TemplateCanvasControl.IsMultiSelectionActive` first, disabling the whole panel and showing
+ "N items selected" instead of one arbitrary member's values; the Delete button/key are disabled
+ during multi-select rather than extended to a bulk operation (out of this story's scope).
+
+Tests: 12 new Desktop.Core tests (`ElementsInRect` including the rotated case, `ToggleMultiSelect`,
+`SetMultiSelection`, `ClearMultiSelect`, `MultiDragTo` including the snap-delta case, `EndMultiDrag`,
+`BeginMultiDrag`'s no-op-when-empty 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 correctly selected 2 of 3 placed elements and excluded the third; the properties panel
+showed `Enabled=False`/"2 items selected."; toggling a third element in and a first element back out
+produced the exact expected membership; a group drag moved two multi-selected elements by an
+identical measured delta while their relative offset stayed exactly unchanged and an unselected
+third element stayed untouched; clicking empty canvas space cleared the selection back to zero; a
+real canvas screenshot confirmed every multi-selected element shows the highlight border. One
+disclosed evidence-depth caveat: `Control.ModifierKeys` (the real mechanism `OnMouseDown` reads to
+detect a held Ctrl/Shift) has no public setter and queries live physical keyboard state, so it could
+not be driven through a real simulated key-press in this non-interactive harness — the toggle path
+was instead verified by calling the same private `ToggleElementMultiSelect` method the modifier-click
+branch delegates to, directly, proving that logic correct while disclosing (not overclaiming) that
+the `ModifierKeys` read itself wasn't exercised end-to-end. One pre-existing, unrelated cosmetic gap
+was found (not fixed) during this verification and logged separately: a plain element's selection
+highlight border visibly wraps only part of a multi-word `DisplayText` — present identically for
+ordinary single-selection, confirmed unrelated to this story's own changes (see
+`logs/technical_debt_log.md`, 2026-10-27).
+
**Card**
As a **print operator**, I want to select more than one layout element at a time, so that I can
apply the same operation (like alignment) to several elements at once instead of one at a time.
@@ -107,13 +163,13 @@ apply the same operation (like alignment) to several elements at once instead of
selected item) rather than a fixed requirement here.
**Confirmation (Acceptance Criteria)**
-- [ ] An operator can select multiple elements via rubber-band drag on empty canvas space.
-- [ ] An operator can add or remove a single element from the current selection via a modifier
+- [x] An operator can select multiple elements via rubber-band drag on empty canvas space.
+- [x] An operator can add or remove a single element from the current selection via a modifier
click.
-- [ ] All selected elements are visibly indicated as selected on the canvas.
-- [ ] Dragging any selected element moves the entire selection together, preserving each
+- [x] All selected elements are visibly indicated as selected on the canvas.
+- [x] Dragging any selected element moves the entire selection together, preserving each
element's relative offset.
-- [ ] Verified with an actual built-form smoke (this touches the properties panel's behavior for
+- [x] Verified with an actual built-form smoke (this touches the properties panel's behavior for
a multi-item selection, per the Sprint 6 retrospective action).
**Estimate:** 5 points (dev-team, 2026-10-16, via inspection of `CanvasElementEditor` and
diff --git a/backlog/sprints/sprint-10.md b/backlog/sprints/sprint-10.md
index 466cd8d..8460aa1 100644
--- a/backlog/sprints/sprint-10.md
+++ b/backlog/sprints/sprint-10.md
@@ -8,7 +8,7 @@
| Story | Size | Status | Tasks |
|---|---|---|---|
| Add a live wrap/clip indicator for text elements | 3 points | Done | - [x] Determine, per selected/previewed element with a box, whether the resolved text's natural (unwrapped) GDI+-measured size exceeds the box width (wrapping) and/or the wrapped height exceeds the box height (clipping)
- [x] Draw a small visual indicator (dashed-orange-style, consistent with the existing unmapped-column warning) on `TemplateCanvasControl` and `TemplatePreviewControl` when either condition is true for the current sample record, drawn inside the existing per-element rotation transform so it rotates for free
- [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free)
- [x] Live built-form smoke: a narrow (120pt) boxed element's real canvas screenshot shows both genuine multi-line wrap and the dashed-orange indicator appearing correctly |
-| Select multiple elements at once on the canvas | 5 points | Not Started | - [ ] 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
- [ ] 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
- [ ] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest
- [ ] Visible selection indication for every selected item, not just the primary one
- [ ] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning)
- [ ] 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)
- [ ] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core)
- [ ] 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
- [x] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release
- [x] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest
- [x] Visible selection indication for every selected item, not just the primary one
- [x] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning)
- [x] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes)
- [x] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core)
- [x] Live built-form smoke (touches the properties panel's multi-item behavior) |
| Align and distribute multiple elements | 5 points | Not Started | - [ ] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height)
- [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection
- [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise
- [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free)
- [ ] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) |
## Notes
@@ -35,3 +35,4 @@ Sequenced by dependency and risk: the small, independent indicator story first (
|---|---|---|---|---|
| 1 | 2026-10-27 | **Batch 1** ("Add a live wrap/clip indicator for text elements", 3 points) done, all ACs met. New framework-free `WrapClipDetector` (Desktop.Core) compares an element's natural (unwrapped) GDI+-measured width against its box width, and its GDI+-wrapped height against an explicit clip ceiling when one is set (never flags clipping otherwise, since there is no ceiling to exceed). Wired into `TemplateCanvasControl.DrawElement` and `TemplatePreviewControl.DrawItem`'s existing box-mode branches, drawing a dashed-orange outline (the unmapped-column warning's visual language) inside the same rotation transform every other per-element visual already uses, so it orbits for free when rotated. **Mid-batch, the human product owner raised two live-build issues, addressed together before continuing to Batch 2** (full detail in `epics/02_template_designer_gui_foundation.md`'s and `epics/04_live_preview_and_record_navigation.md`'s post-Sprint-9 correction notes): (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to the Delete/Backspace key and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely, `ResizeDragTo` now only ever sets `Width`, and `TextElementLayout.HasBox`/`TemplateElement.HasBox` were decoupled from `Height` (a new independent `HasHeightClip` flag) as a direct consequence, with `DebenuPdfRenderer.AddPage` gaining a "Width alone" native-wrap-no-clip render path (`DrawWrappedText` unrotated, `GetWrappedText`+`DrawRotatedMultiLineText` rotated). Tests: net +12 (8 obsolete font-scale tests removed, 20 new/updated across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, `TemplateXmlParserTests`, `DebenuPdfRendererWrapTests`, plus the new `WrapClipDetectorTests`). Full suite 476/476 -> 488/488. Live-verified: a real, licensed end-to-end CLI render of a width-only (no height) template showed correct unrotated and rotated auto-height wrap with no clipping; a reflection-driven built-form harness confirmed a real mouse-driven resize drag sets only `Width` (Height/FontSize unchanged), the real Delete key removes the selected element, and a real canvas screenshot shows both genuine wrap and the new indicator on a narrow boxed element. One disclosed evidence-depth caveat: the toolbar Delete button's own `PerformClick()` didn't reliably fire in the synthetic harness (a known WinForms quirk absent a real shown window) — the underlying method was proven correct via a direct call, and the button's wiring matches every other proven-working toolbar button. | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. |
| 2 | 2026-10-27 | **Ad-hoc regression fix, before starting Batch 2:** user reported clicking on and moving a dynamic placeholder had become "not smooth any more." Full detail in `epics/02_template_designer_gui_foundation.md`'s new post-Sprint-10-Batch-1 note. Root cause, found by direct `Stopwatch` instrumentation of `TemplateCanvasControl.OnMouseDown` rather than guessing: `SelectionChanged` fired unconditionally on every left-click, including a click on an already-selected element (the normal way to start a drag), and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time that fires — a cost that only became perceptible (~40-60ms/click, measured) once this sprint's added property-panel rows made the refresh heavier. Fixed by only raising `SelectionChanged` when the pre-click and post-click selection identity actually differ. Live-verified with the same instrumented harness: an already-selected element's second click dropped from ~50ms to 0.295ms; a genuine selection change still pays the warranted refresh cost unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix is isolated to the WinForms-only `TemplateCanvasControl`, which has no automated test project). | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. |
+| 3 | 2026-10-27 | **Batch 2** ("Select multiple elements at once on the canvas", 5 points) done, all ACs met. Built as a strictly additive layer per the story's own conversation notes: `CanvasElementEditor.MultiSelected` (a `HashSet`) alongside the existing `Selected` field, and a parallel `TemplateCanvasControl._multiSelectedAddressControls` for Address Controls, with a `CollapseMultiSelectionIfSingular` rule ensuring every existing single-select code path keeps working unchanged whenever 0 or 1 items end up selected. Rubber-band selection (`CanvasElementEditor.ElementsInRect`, rotation-aware via rotated-corner AABB, plus a parallel `TemplateCanvasControl.AddressControlsInRect`) and modifier-click toggle (`ToggleMultiSelect`, seeding from the current single selection) both replace or extend the multi-selection on release/click; group drag (`BeginMultiDrag`/`MultiDragTo`, snapping the shared *delta* once rather than each item's position independently) moves the whole set together preserving relative offsets; `DrawAddressControl` was split into separate `isPrimarySelected` (handles) and `isMultiSelected` (highlight-only) parameters so every selected item is visibly marked without ambiguous multi-item handles; the properties panel shows "N items selected" and disables per-item editing (Delete disabled too, rather than extended to a bulk operation — out of this story's scope). Tests: +12 (Desktop.Core, including a rotated-element `ElementsInRect` case and a snap-to-grid `MultiDragTo` case). Full suite 364/364 -> 376/376 (Desktop.Core; CLI unaffected at 124/124). Live-verified with a reflection-driven harness against the real built form: rubber-band selected exactly the 2 of 3 elements it should; the panel showed the disabled "2 items selected." state; toggling a third item in and the first back out produced the exact expected membership; a group drag moved two elements by an identical measured delta with their relative offset exactly unchanged and the unselected third element untouched; clicking empty space cleared the selection; a real canvas screenshot confirmed every multi-selected item shows the highlight border. One disclosed evidence-depth caveat: `Control.ModifierKeys` has no public setter and queries live keyboard state, so modifier-click was verified by calling the same private toggle method the click handler delegates to, directly, rather than through a real simulated key-press. One pre-existing, unrelated cosmetic gap found (not fixed) during verification: a plain element's selection highlight border visibly wraps only part of a multi-word display string — confirmed present identically for ordinary single-selection, logged to `logs/technical_debt_log.md` (2026-10-27) rather than fixed in-scope. | Batch 3, "Align and distribute multiple elements" (5 points, epic 6, depends on Batch 2's multi-selection now existing). | None. |
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
index da0bea1..e3b26c9 100644
--- a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
@@ -37,6 +37,17 @@ public sealed class CanvasElementEditor
private bool _isRotating;
private bool _isResizingWidth;
+ /// Sprint 10, "Select multiple elements at once on the canvas": an *additive* layer
+ /// alongside , not a replacement — every existing single-select code
+ /// path (properties panel, rotate/resize handles, line drill-in) keeps reading
+ /// unchanged. is responsible for the
+ /// "collapse to single-select when exactly one item ends up selected, clear
+ /// when two or more are" rule, since it is the only place that knows about both this set and the
+ /// parallel Address Control multi-selection set.
+ private readonly HashSet _multiSelected = new();
+ private (double X, double Y)? _multiDragGrabPoint;
+ private Dictionary? _multiDragOriginalPositions;
+
public CanvasElementEditor(
TemplateLayoutDocument document, Func measureText)
{
@@ -148,6 +159,145 @@ public sealed class CanvasElementEditor
public void Select(TextElementLayout? element) => Selected = element;
+ /// Sprint 10: the standalone elements currently part of the multi-selection — see
+ /// this field's own remarks for the collapse rule
+ /// applies.
+ public IReadOnlySet MultiSelected => _multiSelected;
+
+ /// Replaces the entire multi-selection set (used by rubber-band selection, which
+ /// determines the whole new set at once from a rectangle rather than toggling one item at a
+ /// time).
+ public void SetMultiSelection(IEnumerable elements)
+ {
+ _multiSelected.Clear();
+ foreach (var element in elements)
+ {
+ _multiSelected.Add(element);
+ }
+ }
+
+ /// Modifier-click support: adds the element if it isn't already part of the
+ /// multi-selection, removes it if it is — every other member is left untouched.
+ public void ToggleMultiSelect(TextElementLayout element)
+ {
+ if (!_multiSelected.Remove(element))
+ {
+ _multiSelected.Add(element);
+ }
+ }
+
+ public void ClearMultiSelect() => _multiSelected.Clear();
+
+ /// Rubber-band selection support: every standalone element whose world-space
+ /// (rotation-aware) axis-aligned bounding box intersects the given rectangle. A rotated
+ /// element's own AABB (the bounding box of its four rotated corners, not its unrotated box) is
+ /// used — a documented simplification consistent with this canvas's other rotation-aware but
+ /// approximate hit-testing (see 's sibling remarks):
+ /// correct for axis-aligned selection rectangles, though a very obliquely rotated element's
+ /// true (rotated) outline could extend slightly beyond what a tight rectangle actually
+ /// touches.
+ public IReadOnlyList ElementsInRect(double minX, double minY, double maxX, double maxY)
+ {
+ var result = new List();
+ foreach (var element in _document.Elements)
+ {
+ var (width, height) = _measureText(element);
+ var (elMinX, elMinY, elMaxX, elMaxY) = WorldBounds(element, width, height);
+ if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
+ {
+ result.Add(element);
+ }
+ }
+
+ return result;
+ }
+
+ private static (double MinX, double MinY, double MaxX, double MaxY) WorldBounds(
+ TextElementLayout element, double width, double height)
+ {
+ if (element.RotationAngle == 0)
+ {
+ return (element.X, element.Y, element.X + width, element.Y + height);
+ }
+
+ var pivot = RotationPivot(element, width, height);
+ var corners = new[]
+ {
+ (element.X, element.Y),
+ (element.X + width, element.Y),
+ (element.X, element.Y + height),
+ (element.X + width, element.Y + height),
+ };
+
+ 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) = RotatePointAroundPivot(cornerX, cornerY, pivot, element.RotationAngle);
+ minX = Math.Min(minX, rx);
+ minY = Math.Min(minY, ry);
+ maxX = Math.Max(maxX, rx);
+ maxY = Math.Max(maxY, ry);
+ }
+
+ return (minX, minY, maxX, maxY);
+ }
+
+ /// Starts a group drag of every element currently in ,
+ /// capturing each one's original position so can move the whole set
+ /// by one shared delta — preserving every item's relative offset, matching how
+ /// / preserve a single element's grab offset.
+ /// No-op if nothing is multi-selected.
+ public void BeginMultiDrag(double grabXPoints, double grabYPoints)
+ {
+ if (_multiSelected.Count == 0)
+ {
+ return;
+ }
+
+ _multiDragGrabPoint = (grabXPoints, grabYPoints);
+ _multiDragOriginalPositions = _multiSelected.ToDictionary(e => e, e => (e.X, e.Y));
+ }
+
+ /// Moves every multi-selected element by the same delta (current pointer position
+ /// minus the grab point) applied to its own captured original position — delta-based, not
+ /// absolute repositioning, so relative offsets between items never drift. When
+ /// , the *delta itself* is snapped once (not each item's final
+ /// position independently), which is what actually preserves relative offsets: independently
+ /// snapping each item's absolute position would pull unevenly-spaced items closer together or
+ /// further apart depending on where each one started relative to the grid.
+ public void MultiDragTo(double xPoints, double yPoints)
+ {
+ if (_multiDragGrabPoint is null || _multiDragOriginalPositions is null)
+ {
+ return;
+ }
+
+ var dx = xPoints - _multiDragGrabPoint.Value.X;
+ var dy = yPoints - _multiDragGrabPoint.Value.Y;
+ if (SnapToGridEnabled)
+ {
+ dx = GridSnapper.Snap(dx, GridSizePoints);
+ dy = GridSnapper.Snap(dy, GridSizePoints);
+ }
+
+ foreach (var (element, original) in _multiDragOriginalPositions)
+ {
+ element.X = original.X + dx;
+ element.Y = original.Y + dy;
+ }
+ }
+
+ public void EndMultiDrag()
+ {
+ _multiDragGrabPoint = null;
+ _multiDragOriginalPositions = null;
+ }
+
+ public bool IsMultiDragging => _multiDragGrabPoint is not null;
+
/// Post-Sprint-9 user-reported gap: there was no way to delete a placed standalone
/// element at all. Removes the currently selected element from the document and clears the
/// selection; a no-op if nothing is selected.
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
index a9506aa..d03a6be 100644
--- a/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
@@ -755,4 +755,193 @@ public class CanvasElementEditorTests
Assert.Equal(50.0, document.Elements[0].X, precision: 6);
Assert.Equal(40.0, document.Elements[0].Y, precision: 6);
}
+
+ // Sprint 10, "Select multiple elements at once on the canvas": an additive layer alongside
+ // Selected (see CanvasElementEditor.MultiSelected's remarks) — these tests cover the
+ // framework-free half (rubber-band hit-testing via ElementsInRect, modifier-click toggle via
+ // ToggleMultiSelect/SetMultiSelection, and multi-drag delta math). The mixed
+ // standalone-element-plus-Address-Control orchestration (collapse-to-single-select rule,
+ // rubber-band rectangle finalization, group-drag coordination across both kinds) lives in the
+ // WinForms-only TemplateCanvasControl and is covered by live/reflection-harness verification
+ // instead, per this project's established split of testing responsibility for that layer.
+
+ [Fact]
+ public void ElementsInRect_UnrotatedElement_SelectsOnlyElementsWhoseBoundingBoxIntersects()
+ {
+ var editor = CreateEditor(out _);
+ var inside = editor.AddStaticText(10, 10, "Inside"); // box: (10,10)-(30,20) with FixedSize
+ var outside = editor.AddStaticText(200, 200, "Outside"); // box: (200,200)-(220,210)
+
+ var hits = editor.ElementsInRect(0, 0, 50, 50);
+
+ Assert.Contains(inside, hits);
+ Assert.DoesNotContain(outside, hits);
+ }
+
+ [Fact]
+ public void ElementsInRect_RectanglePartiallyOverlappingElement_StillCounts()
+ {
+ var editor = CreateEditor(out _);
+ var element = editor.AddStaticText(10, 10, "Hello"); // box: (10,10)-(30,20)
+
+ // Rectangle only clips the element's bottom-left corner — a partial overlap, not a
+ // containment — matching typical rubber-band UX ("touches" selects, not "fully encloses").
+ var hits = editor.ElementsInRect(0, 0, 15, 15);
+
+ Assert.Contains(element, hits);
+ }
+
+ [Fact]
+ public void ElementsInRect_RotatedElement_UsesRotatedCornersNotUnrotatedBox()
+ {
+ var editor = CreateEditor(out _);
+ // A 20x10 box at (0,0) rotated 90 degrees around its own bounding-box center (10,5) swings
+ // its corners out to roughly (5,-5)-(15,15) in world space — a point in that swung-out
+ // region that is NOT inside the original unrotated (0,0)-(20,10) box proves the rotated
+ // corners (not the unrotated box) drive the intersection test.
+ var element = editor.AddStaticText(0, 0, "Hello");
+ element.RotationAngle = 90;
+
+ var hitsSwungRegion = editor.ElementsInRect(4, -6, 6, -4); // just outside the unrotated box's Y range
+ var missesFarAway = editor.ElementsInRect(100, 100, 120, 110);
+
+ Assert.Contains(element, hitsSwungRegion);
+ Assert.Empty(missesFarAway);
+ }
+
+ [Fact]
+ public void ToggleMultiSelect_ElementNotSelected_AddsIt()
+ {
+ var editor = CreateEditor(out _);
+ var element = editor.AddStaticText(0, 0, "Hello");
+
+ editor.ToggleMultiSelect(element);
+
+ Assert.Contains(element, editor.MultiSelected);
+ }
+
+ [Fact]
+ public void ToggleMultiSelect_ElementAlreadySelected_RemovesItWithoutDisturbingOthers()
+ {
+ var editor = CreateEditor(out _);
+ var a = editor.AddStaticText(0, 0, "A");
+ var b = editor.AddStaticText(100, 100, "B");
+ editor.SetMultiSelection(new[] { a, b });
+
+ editor.ToggleMultiSelect(a);
+
+ Assert.DoesNotContain(a, editor.MultiSelected);
+ Assert.Contains(b, editor.MultiSelected);
+ }
+
+ [Fact]
+ public void SetMultiSelection_ReplacesWhateverWasThereBefore()
+ {
+ var editor = CreateEditor(out _);
+ var a = editor.AddStaticText(0, 0, "A");
+ var b = editor.AddStaticText(100, 100, "B");
+ editor.SetMultiSelection(new[] { a });
+
+ editor.SetMultiSelection(new[] { b });
+
+ Assert.DoesNotContain(a, editor.MultiSelected);
+ Assert.Contains(b, editor.MultiSelected);
+ }
+
+ [Fact]
+ public void ClearMultiSelect_EmptiesTheSet()
+ {
+ var editor = CreateEditor(out _);
+ var element = editor.AddStaticText(0, 0, "Hello");
+ editor.ToggleMultiSelect(element);
+
+ editor.ClearMultiSelect();
+
+ Assert.Empty(editor.MultiSelected);
+ }
+
+ [Fact]
+ public void MultiDragTo_MovesEveryMultiSelectedElementByTheSameDelta_PreservingRelativeOffsets()
+ {
+ var editor = CreateEditor(out _);
+ var a = editor.AddStaticText(0, 0, "A");
+ var b = editor.AddStaticText(100, 50, "B");
+ editor.SetMultiSelection(new[] { a, b });
+ editor.BeginMultiDrag(0, 0);
+
+ editor.MultiDragTo(10, 5);
+
+ Assert.Equal(10.0, a.X, precision: 6);
+ Assert.Equal(5.0, a.Y, precision: 6);
+ Assert.Equal(110.0, b.X, precision: 6); // moved by the same (10, 5) delta as A
+ Assert.Equal(55.0, b.Y, precision: 6);
+ }
+
+ [Fact]
+ public void MultiDragTo_ContinuesFromTheOriginalCapturedPositions_NotCumulatively()
+ {
+ // Each call recomputes from the captured original position and the CURRENT pointer, the
+ // same "recompute from scratch every tick" style DragTo already uses — calling MultiDragTo
+ // twice with different endpoints must not compound like two successive relative moves.
+ var editor = CreateEditor(out _);
+ var element = editor.AddStaticText(0, 0, "Hello");
+ editor.SetMultiSelection(new[] { element });
+ editor.BeginMultiDrag(0, 0);
+
+ editor.MultiDragTo(10, 10);
+ editor.MultiDragTo(30, 5);
+
+ Assert.Equal(30.0, element.X, precision: 6);
+ Assert.Equal(5.0, element.Y, precision: 6);
+ }
+
+ [Fact]
+ public void MultiDragTo_SnapEnabled_SnapsTheDeltaItself_NotEachItemsFinalPositionIndependently()
+ {
+ // Snapping each item's absolute position independently would pull unevenly-grid-aligned
+ // items closer together or further apart; snapping the shared delta once preserves the
+ // exact original offset between A and B regardless of where either started relative to
+ // the grid.
+ var editor = CreateEditor(out _);
+ var a = editor.AddStaticText(0, 0, "A");
+ var b = editor.AddStaticText(3, 7, "B"); // deliberately off-grid relative to A
+ editor.SnapToGridEnabled = true;
+ editor.GridSizePoints = 10;
+ editor.SetMultiSelection(new[] { a, b });
+ editor.BeginMultiDrag(0, 0);
+
+ editor.MultiDragTo(24, 1); // raw delta (24, 1) snaps to (20, 0)
+
+ Assert.Equal(20.0, a.X, precision: 6);
+ Assert.Equal(0.0, a.Y, precision: 6);
+ Assert.Equal(23.0, b.X, precision: 6); // original (3,7) + the same snapped (20, 0) delta
+ Assert.Equal(7.0, b.Y, precision: 6);
+ }
+
+ [Fact]
+ public void EndMultiDrag_StopsFurtherMovement()
+ {
+ var editor = CreateEditor(out _);
+ var element = editor.AddStaticText(0, 0, "Hello");
+ editor.SetMultiSelection(new[] { element });
+ editor.BeginMultiDrag(0, 0);
+ editor.MultiDragTo(10, 10);
+
+ editor.EndMultiDrag();
+ editor.MultiDragTo(999, 999);
+
+ Assert.Equal(10.0, element.X, precision: 6); // unchanged by the post-EndMultiDrag call
+ Assert.False(editor.IsMultiDragging);
+ }
+
+ [Fact]
+ public void BeginMultiDrag_NothingMultiSelected_IsANoOp()
+ {
+ var editor = CreateEditor(out _);
+ editor.AddStaticText(0, 0, "Hello");
+
+ editor.BeginMultiDrag(0, 0);
+
+ Assert.False(editor.IsMultiDragging);
+ }
}
diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
index e8e342f..0796853 100644
--- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
+++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
@@ -30,6 +30,27 @@ public sealed class TemplateCanvasControl : Control
/// ever operates on a selected standalone .
private bool _isRotatingAddressControl;
+ /// Sprint 10, "Select multiple elements at once on the canvas": the Address Control
+ /// half of the multi-selection — mirrors for
+ /// standalone elements, since has no knowledge of Address
+ /// Controls at all (the same split as single-selection's
+ /// vs. CanvasElementEditor.Selected). An *additive* layer: every existing single-select
+ /// code path keeps working unchanged whenever this set (plus the editor's) totals fewer than
+ /// two items — see .
+ private readonly HashSet _multiSelectedAddressControls = new();
+
+ private bool _isRubberBandSelecting;
+ private bool _rubberBandAdditive;
+ private (double X, double Y)? _rubberBandStart;
+ private (double X, double Y)? _rubberBandCurrent;
+
+ /// Grab point and original positions for the Address Control half of an in-progress
+ /// group drag — mirrors /MultiDragTo for
+ /// standalone elements, applied to both halves from the same shared delta every tick so the
+ /// whole mixed multi-selection moves together.
+ private (double X, double Y)? _multiDragGrabPoint;
+ private Dictionary? _multiDragOriginalAddressPositions;
+
/// The currently loaded CSV's headers and one representative sample record, used
/// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) —
/// empty/null until is called after a CSV loads.
@@ -95,11 +116,24 @@ public sealed class TemplateCanvasControl : Control
/// property existed.
public bool IsInteracting =>
_editor.IsDragging || _editor.IsRotating || _editor.IsResizingWidth || _addressControlDragOffset is not null
- || _isResizingAddressControl || _isRotatingAddressControl;
+ || _isResizingAddressControl || _isRotatingAddressControl || _editor.IsMultiDragging;
public TextElementLayout? SelectedElement => _editor.Selected;
public AddressControlLayout? SelectedAddressControl => _selectedAddressControl;
public int SelectedAddressLineIndex => _selectedAddressLineIndex;
+
+ /// Sprint 10, "Select multiple elements at once on the canvas": the combined size of
+ /// the multi-selection across both standalone elements and Address Controls. Zero or one means
+ /// no *active* multi-selection — see and
+ /// for why a single leftover item always
+ /// collapses back into the ordinary single-selection fields instead of staying here.
+ public int MultiSelectionCount => _editor.MultiSelected.Count + _multiSelectedAddressControls.Count;
+
+ /// True while two or more items (any mix of standalone elements and Address Controls)
+ /// are selected together. uses this to show "N items
+ /// selected" and disable per-item property editing instead of displaying stale/misleading
+ /// single-item values.
+ public bool IsMultiSelectionActive => MultiSelectionCount >= 2;
public AddressControlLineLayout? SelectedAddressLine =>
_selectedAddressControl is not null
&& _selectedAddressLineIndex >= 0
@@ -228,11 +262,16 @@ public sealed class TemplateCanvasControl : Control
public void ClearSelection()
{
_editor.Select(null);
+ _editor.ClearMultiSelect();
_selectedAddressControl = null;
_selectedAddressLineIndex = 0;
_addressControlDragOffset = null;
_isResizingAddressControl = false;
_isRotatingAddressControl = false;
+ _multiSelectedAddressControls.Clear();
+ _isRubberBandSelecting = false;
+ _rubberBandStart = null;
+ _rubberBandCurrent = null;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
@@ -326,7 +365,14 @@ public sealed class TemplateCanvasControl : Control
continue;
}
- DrawElement(g, transform, item.Text, state, isSelected: ReferenceEquals(item.Text, _editor.Selected));
+ // Sprint 10, "Select multiple elements at once on the canvas": a multi-selected
+ // element gets the same highlight border as the single primary selection (see
+ // DrawElement's isSelected remarks — it only ever drives the highlight, never the
+ // resize/rotate handles, which are drawn separately below gated on
+ // _editor.Selected alone) so every selected item is visibly marked, not just one.
+ DrawElement(
+ g, transform, item.Text, state,
+ isSelected: ReferenceEquals(item.Text, _editor.Selected) || _editor.MultiSelected.Contains(item.Text));
continue;
}
@@ -334,7 +380,8 @@ public sealed class TemplateCanvasControl : Control
g,
transform,
item.Control!,
- isSelected: ReferenceEquals(item.Control, _selectedAddressControl));
+ isPrimarySelected: ReferenceEquals(item.Control, _selectedAddressControl),
+ isMultiSelected: _multiSelectedAddressControls.Contains(item.Control!));
}
if (_editor.Selected is not null)
@@ -342,6 +389,29 @@ public sealed class TemplateCanvasControl : Control
DrawResizeHandle(g, transform, _editor.Selected);
DrawRotateHandle(g, transform, _editor.Selected);
}
+
+ if (_isRubberBandSelecting && _rubberBandStart is not null && _rubberBandCurrent is not null)
+ {
+ DrawRubberBand(g, transform, _rubberBandStart.Value, _rubberBandCurrent.Value);
+ }
+ }
+
+ /// Sprint 10, "Select multiple elements at once on the canvas": the marquee rectangle
+ /// drawn while a rubber-band selection drag is in progress, in canvas-page-agnostic pixel
+ /// space (drawn last, on top of everything else, the same way a rotate/resize handle already
+ /// draws on top of its element).
+ private static void DrawRubberBand(
+ Graphics g, CanvasViewTransform transform, (double X, double Y) start, (double X, double Y) current)
+ {
+ var (x1, y1) = transform.ToPixels(start.X, start.Y);
+ var (x2, y2) = transform.ToPixels(current.X, current.Y);
+ var rect = RectangleF.FromLTRB(
+ (float)Math.Min(x1, x2), (float)Math.Min(y1, y2), (float)Math.Max(x1, x2), (float)Math.Max(y1, y2));
+
+ using var fill = new SolidBrush(System.Drawing.Color.FromArgb(40, System.Drawing.Color.DodgerBlue));
+ using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
+ g.FillRectangle(fill, rect);
+ g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
}
/// Sprint 7: draws light dotted grid lines across the page at every
@@ -391,6 +461,234 @@ public sealed class TemplateCanvasControl : Control
_isRotatingAddressControl = false;
}
+ /// Sprint 10, "Select multiple elements at once on the canvas": empties both halves
+ /// of the multi-selection (standalone elements and Address Controls) without touching the
+ /// ordinary single-selection fields — callers that are about to establish a fresh single
+ /// selection call this first so a stale multi-selection never lingers alongside it.
+ private void ClearMultiSelection()
+ {
+ _editor.ClearMultiSelect();
+ _multiSelectedAddressControls.Clear();
+ }
+
+ /// Applies the multi-selection's one collapse rule after every mutation (a rubber-band
+ /// release, a modifier-click toggle): zero items means nothing is selected at all; exactly one
+ /// item collapses back into the ordinary single-selection fields (
+ /// or ) so every existing single-select code path —
+ /// properties panel, rotate/resize handles, address-line drill-in — keeps working completely
+ /// unchanged; two or more items clears both single-selection fields so the properties panel
+ /// shows "N items selected" instead of stale single-item values.
+ private void CollapseMultiSelectionIfSingular()
+ {
+ var multiElements = _editor.MultiSelected;
+ var total = multiElements.Count + _multiSelectedAddressControls.Count;
+
+ if (total == 0)
+ {
+ _editor.Select(null);
+ ClearAddressSelection();
+ return;
+ }
+
+ if (total == 1)
+ {
+ if (multiElements.Count == 1)
+ {
+ var onlyElement = multiElements.First();
+ _editor.ClearMultiSelect();
+ _editor.Select(onlyElement);
+ ClearAddressSelection();
+ }
+ else
+ {
+ var onlyControl = _multiSelectedAddressControls.First();
+ _multiSelectedAddressControls.Clear();
+ SelectAddressControl(onlyControl, 0);
+ }
+
+ return;
+ }
+
+ // Two or more: neither single-selection field applies while a multi-selection is active.
+ _editor.Select(null);
+ ClearAddressSelection();
+ }
+
+ /// Modifier-click (Ctrl/Shift) support: toggles the given element's membership in the
+ /// multi-selection. If nothing was multi-selected yet, first seeds the set with whatever was
+ /// singly selected — the standard modifier-click UX extends the current selection rather than
+ /// starting over from empty.
+ private void ToggleElementMultiSelect(TextElementLayout element)
+ {
+ SeedMultiSelectionFromSingleSelectionIfEmpty();
+ _editor.ToggleMultiSelect(element);
+ CollapseMultiSelectionIfSingular();
+ }
+
+ private void ToggleAddressControlMultiSelect(AddressControlLayout control)
+ {
+ SeedMultiSelectionFromSingleSelectionIfEmpty();
+ if (!_multiSelectedAddressControls.Remove(control))
+ {
+ _multiSelectedAddressControls.Add(control);
+ }
+
+ CollapseMultiSelectionIfSingular();
+ }
+
+ private void SeedMultiSelectionFromSingleSelectionIfEmpty()
+ {
+ if (_editor.MultiSelected.Count > 0 || _multiSelectedAddressControls.Count > 0)
+ {
+ return;
+ }
+
+ if (_editor.Selected is not null)
+ {
+ _editor.SetMultiSelection(new[] { _editor.Selected });
+ }
+
+ if (_selectedAddressControl is not null)
+ {
+ _multiSelectedAddressControls.Add(_selectedAddressControl);
+ }
+ }
+
+ /// Rubber-band selection support: every Address Control whose world-space
+ /// (rotation-aware) axis-aligned bounding box intersects the given rectangle — the Address
+ /// Control counterpart of , kept here rather
+ /// than in Desktop.Core since it needs , the same WinForms-only
+ /// geometry helper already uses.
+ private List AddressControlsInRect(double minX, double minY, double maxX, double maxY)
+ {
+ var result = new List();
+ foreach (var control in _document.AddressControls)
+ {
+ var top = control.Y + MaxLineFontSize(control);
+ var bottom = control.Y - control.Height;
+ var left = control.X;
+ var right = control.X + control.Width;
+
+ double elMinX, elMinY, elMaxX, elMaxY;
+ if (control.RotationAngle == 0)
+ {
+ (elMinX, elMinY, elMaxX, elMaxY) = (left, bottom, right, top);
+ }
+ else
+ {
+ var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) };
+ elMinX = double.MaxValue;
+ elMinY = double.MaxValue;
+ elMaxX = double.MinValue;
+ elMaxY = double.MinValue;
+ foreach (var (cornerX, cornerY) in corners)
+ {
+ var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle);
+ elMinX = Math.Min(elMinX, rx);
+ elMaxX = Math.Max(elMaxX, rx);
+ elMinY = Math.Min(elMinY, ry);
+ elMaxY = Math.Max(elMaxY, ry);
+ }
+ }
+
+ if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
+ {
+ result.Add(control);
+ }
+ }
+
+ return result;
+ }
+
+ /// Finalizes a rubber-band drag on release: computes the final (normalized) rectangle,
+ /// finds every element/control it intersects, and either replaces the multi-selection with that
+ /// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag,
+ /// ) — mirroring modifier-click's "extend, don't replace"
+ /// behavior for consistency.
+ private void FinalizeRubberBandSelection()
+ {
+ if (_rubberBandStart is not null && _rubberBandCurrent is not null)
+ {
+ var (x1, y1) = _rubberBandStart.Value;
+ var (x2, y2) = _rubberBandCurrent.Value;
+ var minX = Math.Min(x1, x2);
+ var maxX = Math.Max(x1, x2);
+ var minY = Math.Min(y1, y2);
+ var maxY = Math.Max(y1, y2);
+
+ var hitElements = _editor.ElementsInRect(minX, minY, maxX, maxY);
+ var hitControls = AddressControlsInRect(minX, minY, maxX, maxY);
+
+ if (_rubberBandAdditive)
+ {
+ var combinedElements = new HashSet(_editor.MultiSelected);
+ foreach (var element in hitElements)
+ {
+ combinedElements.Add(element);
+ }
+
+ _editor.SetMultiSelection(combinedElements);
+ foreach (var control in hitControls)
+ {
+ _multiSelectedAddressControls.Add(control);
+ }
+ }
+ else
+ {
+ _editor.SetMultiSelection(hitElements);
+ _multiSelectedAddressControls.Clear();
+ foreach (var control in hitControls)
+ {
+ _multiSelectedAddressControls.Add(control);
+ }
+ }
+
+ CollapseMultiSelectionIfSingular();
+ }
+
+ _isRubberBandSelecting = false;
+ _rubberBandStart = null;
+ _rubberBandCurrent = null;
+ Invalidate();
+ SelectionChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void BeginGroupDrag(double grabXPoints, double grabYPoints)
+ {
+ _editor.BeginMultiDrag(grabXPoints, grabYPoints);
+ _multiDragGrabPoint = (grabXPoints, grabYPoints);
+ _multiDragOriginalAddressPositions = _multiSelectedAddressControls.ToDictionary(c => c, c => (c.X, c.Y));
+ }
+
+ private void ApplyMultiDragToAddressControls(double xPoints, double yPoints)
+ {
+ if (_multiDragGrabPoint is null || _multiDragOriginalAddressPositions is null)
+ {
+ return;
+ }
+
+ var dx = xPoints - _multiDragGrabPoint.Value.X;
+ var dy = yPoints - _multiDragGrabPoint.Value.Y;
+ if (SnapToGridEnabled)
+ {
+ dx = GridSnapper.Snap(dx, GridSizePoints);
+ dy = GridSnapper.Snap(dy, GridSizePoints);
+ }
+
+ foreach (var (control, original) in _multiDragOriginalAddressPositions)
+ {
+ control.X = original.X + dx;
+ control.Y = original.Y + dy;
+ }
+ }
+
+ private void EndGroupDrag()
+ {
+ _editor.EndMultiDrag();
+ _multiDragGrabPoint = null;
+ _multiDragOriginalAddressPositions = null;
+ }
+
private void DrawElement(
Graphics g, CanvasViewTransform transform, TextElementLayout element,
ElementPreviewState state, bool isSelected)
@@ -585,7 +883,8 @@ public sealed class TemplateCanvasControl : Control
/// together as one rigid unit — matching what and
/// independently confirm by rotating the click point
/// back into this same unrotated local space before testing.
- private void DrawAddressControl(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected)
+ private void DrawAddressControl(
+ Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected)
{
GraphicsState? savedState = null;
if (control.RotationAngle != 0)
@@ -603,7 +902,7 @@ public sealed class TemplateCanvasControl : Control
try
{
- DrawAddressControlUnrotated(g, transform, control, isSelected);
+ DrawAddressControlUnrotated(g, transform, control, isPrimarySelected, isMultiSelected);
}
finally
{
@@ -614,8 +913,17 @@ public sealed class TemplateCanvasControl : Control
}
}
- private void DrawAddressControlUnrotated(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected)
+ /// Sprint 10, "Select multiple elements at once on the canvas": and are deliberately separate
+ /// — both draw the same highlighted border (so every selected item is visibly marked), but
+ /// only the primary selection gets the resize/rotate handles and the drilled-into-line
+ /// highlight, so a multi-selected control never shows handles that would be ambiguous about
+ /// which item they act on (out of scope for this story; align/distribute operates on the whole
+ /// set instead).
+ private void DrawAddressControlUnrotated(
+ Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected)
{
+ var isHighlighted = isPrimarySelected || isMultiSelected;
var lineStates = ComputeAddressControlLineStates(control);
using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash };
using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1);
@@ -623,8 +931,8 @@ public sealed class TemplateCanvasControl : Control
var (left, top) = transform.ToPixels(control.X, control.Y + MaxLineFontSize(control));
var (right, bottom) = transform.ToPixels(control.X + control.Width, control.Y - control.Height);
var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom);
- g.DrawRectangle(isSelected ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height);
- if (isSelected)
+ g.DrawRectangle(isHighlighted ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height);
+ if (isPrimarySelected)
{
DrawAddressResizeHandle(g, transform, control);
// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
@@ -661,7 +969,7 @@ public sealed class TemplateCanvasControl : Control
using var brush = new SolidBrush(System.Drawing.Color.FromArgb(line.Color.R, line.Color.G, line.Color.B));
g.DrawString(text, font, brush, (float)drawX, (float)drawY);
- if (isSelected && i == _selectedAddressLineIndex)
+ if (isPrimarySelected && i == _selectedAddressLineIndex)
{
using var linePen = new Pen(System.Drawing.Color.MediumSeaGreen, 1);
g.DrawRectangle(
@@ -903,6 +1211,12 @@ public sealed class TemplateCanvasControl : Control
var previousSelectedAddressControl = _selectedAddressControl;
var previousSelectedAddressLineIndex = _selectedAddressLineIndex;
+ // Sprint 10, "Select multiple elements at once on the canvas": Ctrl or Shift held during
+ // the click means "toggle this one item's membership in the multi-selection" (or, for an
+ // empty-space click below, "add to the multi-selection via rubber-band" rather than
+ // replacing it) — checked once up front since every hit-test branch below needs it.
+ var isModifierClick = (ModifierKeys & (Keys.Control | Keys.Shift)) != 0;
+
var transform = CurrentTransform();
var (x, y) = transform.ToPoints(e.X, e.Y);
@@ -929,6 +1243,27 @@ public sealed class TemplateCanvasControl : Control
var addressHit = HitTestAddressControl(x, y);
if (addressHit.Control is not null)
{
+ if (isModifierClick)
+ {
+ ToggleAddressControlMultiSelect(addressHit.Control);
+ Invalidate();
+ RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
+ return;
+ }
+
+ if (IsMultiSelectionActive && _multiSelectedAddressControls.Contains(addressHit.Control))
+ {
+ // Clicking a member of an existing multi-selection (no modifier) starts a group
+ // drag of the whole set instead of collapsing back to a single selection — the
+ // same "click an already-selected item to move it" gesture single-select has
+ // always supported, extended to the group.
+ BeginGroupDrag(x, y);
+ Capture = true;
+ Invalidate();
+ return;
+ }
+
+ ClearMultiSelection();
SelectAddressControl(addressHit.Control, addressHit.LineIndex);
_addressControlDragOffset = (x - addressHit.Control.X, y - addressHit.Control.Y);
Capture = true;
@@ -962,13 +1297,48 @@ public sealed class TemplateCanvasControl : Control
return;
}
- var hit = _editor.TrySelectAt(x, y);
- if (hit)
+ var hitElement = _editor.HitTest(x, y);
+ if (hitElement is not null)
{
+ if (isModifierClick)
+ {
+ ToggleElementMultiSelect(hitElement);
+ Invalidate();
+ RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
+ return;
+ }
+
+ if (IsMultiSelectionActive && _editor.MultiSelected.Contains(hitElement))
+ {
+ BeginGroupDrag(x, y);
+ Capture = true;
+ Invalidate();
+ return;
+ }
+
+ ClearMultiSelection();
+ _editor.Select(hitElement);
_editor.BeginDrag(x, y);
Capture = true;
+ Invalidate();
+ RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
+ return;
+ }
+
+ // Nothing hit: start a rubber-band selection rather than just clearing the selection — a
+ // zero-size drag (a plain click with no movement) naturally selects nothing on release,
+ // reproducing the old "click empty space to deselect" behavior without special-casing it.
+ if (!isModifierClick)
+ {
+ ClearMultiSelection();
}
+ _editor.Select(null);
+ _isRubberBandSelecting = true;
+ _rubberBandAdditive = isModifierClick;
+ _rubberBandStart = (x, y);
+ _rubberBandCurrent = (x, y);
+ Capture = true;
Invalidate();
RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
}
@@ -1052,6 +1422,18 @@ public sealed class TemplateCanvasControl : Control
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
+
+ // Checked before the IsInteracting gate below: a rubber-band drag doesn't change any
+ // element's data (so it never needs ElementsChanged/the property-panel refresh that gate
+ // exists to guard), it only needs the marquee rectangle to repaint on every tick.
+ if (_isRubberBandSelecting)
+ {
+ var (rbX, rbY) = CurrentTransform().ToPoints(e.X, e.Y);
+ _rubberBandCurrent = (rbX, rbY);
+ Invalidate();
+ return;
+ }
+
if (!IsInteracting)
{
return;
@@ -1059,6 +1441,15 @@ public sealed class TemplateCanvasControl : Control
var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
+ if (_editor.IsMultiDragging)
+ {
+ _editor.MultiDragTo(x, y);
+ ApplyMultiDragToAddressControls(x, y);
+ Invalidate();
+ ElementsChanged?.Invoke(this, EventArgs.Empty);
+ return;
+ }
+
if (_selectedAddressControl is not null && _isRotatingAddressControl)
{
AddressControlRotateHandle.RotateDragTo(_selectedAddressControl, x, y);
@@ -1119,8 +1510,17 @@ public sealed class TemplateCanvasControl : Control
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
+
+ if (_isRubberBandSelecting)
+ {
+ Capture = false;
+ FinalizeRubberBandSelection();
+ return;
+ }
+
var wasInteracting = IsInteracting;
_editor.EndDrag();
+ EndGroupDrag();
_addressControlDragOffset = null;
_isResizingAddressControl = false;
_isRotatingAddressControl = false;
diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
index 03d5bb6..4d50bd4 100644
--- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
+++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
@@ -1045,6 +1045,18 @@ public sealed class TemplateDesignerForm : Form
/// properties-panel edit never disagree about the element's current values.
private void RefreshPropertiesPanel()
{
+ // Sprint 10, "Select multiple elements at once on the canvas": while two or more items
+ // are selected together, per-item property editing is disabled outright rather than
+ // showing one arbitrary member's values (which would silently mislead about what's
+ // actually selected, and editing them would only ever apply to that one item despite
+ // looking like a bulk edit). Align/distribute (the next story) operates on the whole set
+ // without going through these per-item fields at all.
+ if (_canvas.IsMultiSelectionActive)
+ {
+ _propertiesPanel.Enabled = false;
+ return;
+ }
+
var selected = _canvas.SelectedElement;
var selectedAddressControl = _canvas.SelectedAddressControl;
var selectedAddressLine = _canvas.SelectedAddressLine;
@@ -1216,6 +1228,20 @@ public sealed class TemplateDesignerForm : Form
// built .exe. TextElementLayout.DisplayText already generalizes this exact concatenation
// (literal runs verbatim, field runs as `{ColumnName}`) for the static/dynamic/mixed
// cases uniformly, so use it here directly instead of re-deriving the old two-case logic.
+ // Sprint 10, "Select multiple elements at once on the canvas": checked first since an
+ // active multi-selection always leaves the single-selection fields below null (see
+ // CanvasElementEditor.MultiSelected's remarks) — showing "No element selected" there would
+ // be actively wrong, not just uninformative. Delete is disabled rather than extended to a
+ // bulk operation: multi-item delete isn't in this story's scope, and a Delete keypress
+ // during multi-select already safely no-ops (RemoveSelectedElement only ever acts on the
+ // single-selection fields, both null here) rather than doing anything undefined.
+ if (_canvas.IsMultiSelectionActive)
+ {
+ _selectionLabel.Text = $"{_canvas.MultiSelectionCount} items selected.";
+ _deleteSelectedButton.Enabled = false;
+ return;
+ }
+
var selected = _canvas.SelectedElement;
if (selected is not null)
{
diff --git a/logs/technical_debt_log.md b/logs/technical_debt_log.md
index 6d43cf3..e1adcaf 100644
--- a/logs/technical_debt_log.md
+++ b/logs/technical_debt_log.md
@@ -13,4 +13,5 @@ Append-only log of known technical debt. Maintained by `.claude/agents/qa-tech-d
| 2026-10-19 | Address Control lines rendered on the designer canvas (`TemplateCanvasControl.DrawAddressControl`) and, since Sprint 7, on the new record-accurate preview panel (`TemplatePreviewControl`) visually overlap when a control uses a small font size with the default 1.25 line-spacing multiplier (observed with `sample-envelope-template3.xml`'s 8pt lines against the real sample CSV): each line's GDI+ `MeasureString` height is taller than the `FontSize * LineSpacingMultiplier` row height the baseline math advances by, so consecutive lines' bounding boxes visually overlap on screen even though their baselines are correctly spaced per `TEMPLATE_FORMAT.md`. Confirmed via live built-`.exe` screenshots of both surfaces during Sprint 7 batch 1 verification — present identically on the pre-existing (Sprint 6) design canvas, so this is not a Sprint 7 regression, just newly re-observed because a second surface now shares the same measurement approach. | Unintentional (side effect of using GDI+ `MeasureString`'s full line height as a stand-in for the real Debenu-rendered glyph height, which the code's own remarks already flag as "a design-time visual approximation... not a guarantee of pixel-for-point parity with the final PDF") | Low (cosmetic, canvas/preview-only — the actual PDF render path, `DebenuPdfRenderer`/`RenderEngine`, does not use this measurement at all and is unaffected; does not block reading resolved text, just makes tightly-spaced small-font address blocks visually crowded in the designer) | Open | Not fixed as part of Sprint 7 (out of scope for both the preview-panel and snap-to-grid stories). Candidate direction: derive each line's row height from the same font's ascent/descent (or a smaller line-height fraction of `MeasureString`) instead of the raw measured string height, or accept a documented minimum recommended `LineSpacingMultiplier` for small fonts. Revisit if an operator reports this as more than cosmetic. **Product-owner review confirmation (Sprint 7 Review, 2026-10-19):** Impact/status agreed as logged — Low, Open, non-blocking. Independently confirmed the root cause is confined to `TemplateCanvasControl`/`TemplatePreviewControl`'s GDI+ measurement path and that neither `DebenuPdfRenderer` nor `RenderEngine` (the real PDF render path) references this measurement at all, so no print-output correctness is at risk. No pushback on leaving this open; agree it should not block Sprint 8 planning. |
| 2026-09-04 | `EnvelopeRenderer.Cli` only read `DEBENU_LICENSE_KEY` from the process environment. That's fine for `dotnet run --project ... --` (the CLI inherits the invoking shell's env directly), but `EnvelopeRenderer.Desktop` launches the CLI as a child process, which only inherits whatever environment variables were already present in whatever launched the desktop app itself (a double-clicked `.exe` or Start Menu shortcut typically has none) — so every desktop-launched render failed with Debenu error 999 regardless of a valid key existing on disk. Real-user-reported: the operator correctly guessed a `key.txt` dropped next to the exe should work (matching how the CLI's own test helper already resolved keys), but production code had no such fallback. This is a real Definition-of-Done verification gap from Sprint 1 Batches 4-5: the "real success run" verification used an in-process test harness with the env var set directly in that process, never the actual built `.exe` launched the way an operator would, so the gap wasn't caught before Sprint Review. | Unintentional | High (silently broke the desktop app's core success path for any non-`dotnet run` launch) | Resolved | Added `DebenuLicenseKeyResolver` (`code/src/EnvelopeRenderer.Cli/DebenuLicenseKeyResolver.cs`) to the shipped CLI: env var first, then a `key.txt` walked up from the executable's own directory — the same rule the test-only helper already used, now shared via delegation instead of duplicated. Documented in `CLI_CONTRACT.md`'s "Debenu license key" section and `code/README.md`'s desktop-app instructions. Verified by running the actual built `EnvelopeRenderer.Cli.exe` from the Desktop app's own output folder with no environment variable set at all, `key.txt` sitting next to it: exit `0`, valid 1.3 MB `%PDF-1.4` output, all 392 records. 5 new unit tests added (`DebenuLicenseKeyResolverTests.cs`); full suite 111/111 passing. |
| 2026-10-19 | Post-Sprint-7-review, the plain editing canvas (`TemplateCanvasControl`/`CanvasElementEditor`) now deliberately rotates every element — static and dynamic/mixed alike — around its own bounding-box center (`RotationPivotCalculator.ComputeForCanvasEditing`), instead of the Sprint 6 fixed-anchor rule (`RotationPivotCalculator.Compute(isDynamic, ...)`) it previously shared uniformly with the real render and the new Sprint 7 preview panel. The editing canvas only ever draws an element's literal authored `{ColumnName}` bracket-token text (`TextElementLayout.DisplayText`), never a per-record resolved value, so the record-to-record text-width drift the fixed-anchor rule exists to prevent cannot occur there — applying it anyway just made a dynamic/mixed element's rotate-handle drag swing around a corner instead of spinning in place, a jarring interactive inconsistency the human product owner asked to have fixed after using the Sprint 7 increment. The real render (`RotatedTextAnchorCalculator`/`DebenuPdfRenderer`) and the new preview panel (`TemplatePreviewControl`/`TemplatePreviewBuilder`) are intentionally untouched and still call `RotationPivotCalculator.Compute` with the real `isDynamic` value, so the original 2026-10-09 record-drift defect fix is fully preserved where it actually matters. See the dated note added to the "Keep rotated dynamic and mixed-content fields positioned consistently across records" story in `backlog/epics/02_template_designer_gui_foundation.md` for the full reasoning and human product-owner approval record. | Deliberate (a considered, approved trade-off decoupling a purely cosmetic/interactive editing-canvas behavior from a record-accuracy rule that surface was never actually subject to — not a regression or an oversight) | Low (the editing canvas was never a record-accuracy surface for rotation to begin with, since it never draws resolved per-record text; the real render and record-accurate preview panel, where the original defect actually mattered, are unaffected and keep the exact same fixed-anchor behavior) | Logged (deliberate design decision, not a defect to resolve) | Not applicable — this row documents a considered, already-implemented, and approved design decision for future reference (e.g. if a future story ever gives the editing canvas its own resolved-per-record preview capability, this decoupling would need to be revisited). |
-| 2026-10-26 | Sprint 8 ("Rotate the whole Address Control...") extracted its *new* rigid-group-rotation and drag-handle math into framework-free, unit-tested `EnvelopeRenderer.Desktop.Core.Design` classes (`AddressControlLayout.BoxCenter`, `PointRotation`, `AddressControlRotateHandle`), but the *pre-existing* (Sprint 6) Address Control interaction logic it builds on top of — `TemplateCanvasControl.HitTestAddressControl`, `HitTestAddressResizeHandle`, and the whole-control move/resize mouse handlers — still lives directly in the WinForms `Views` project rather than a `CanvasElementEditor`-equivalent for Address Controls, so it remains untestable by `EnvelopeRenderer.Desktop.Tests` (verified only via a live built-`.exe` reflection-driven smoke, not a unit test) unlike the standalone-element equivalents (`CanvasElementEditor.HitTest`/`HandlePosition`/`RotateDragTo`, which are unit tested). This is a proportional, in-scope decision for this sprint (retroactively refactoring Sprint 6's already-shipped, already-smoke-verified move/resize/hit-test code was not part of either committed story), not an oversight, but it leaves a real, growing architectural inconsistency between the two element kinds' testability. | Unintentional (a natural side effect of adding new, better-architected code alongside older code that predates the pattern, not a deliberate call to leave the old code as-is) | Low (no known behavioral defect — the smoke-tested code paths work correctly per this sprint's live verification; purely a testability/maintainability gap, and the two newly-added rotation classes themselves are fully unit tested) | Open | Candidate direction: extract `TemplateCanvasControl`'s Address-Control-specific hit-test/drag/resize state and math into a new `AddressControlEditor` (Desktop.Core), mirroring `CanvasElementEditor`'s existing role for standalone elements, so all four interaction kinds (select, move, resize, rotate) are unit tested consistently. Not required by any currently-committed story; revisit if Address Control interaction logic grows further (e.g. multi-select epic 6 work) or a live-verified defect is found in it. |
+| 2026-10-26 | Sprint 8 ("Rotate the whole Address Control...") extracted its *new* rigid-group-rotation and drag-handle math into framework-free, unit-tested `EnvelopeRenderer.Desktop.Core.Design` classes (`AddressControlLayout.BoxCenter`, `PointRotation`, `AddressControlRotateHandle`), but the *pre-existing* (Sprint 6) Address Control interaction logic it builds on top of — `TemplateCanvasControl.HitTestAddressControl`, `HitTestAddressResizeHandle`, and the whole-control move/resize mouse handlers — still lives directly in the WinForms `Views` project rather than a `CanvasElementEditor`-equivalent for Address Controls, so it remains untestable by `EnvelopeRenderer.Desktop.Tests` (verified only via a live built-`.exe` reflection-driven smoke, not a unit test) unlike the standalone-element equivalents (`CanvasElementEditor.HitTest`/`HandlePosition`/`RotateDragTo`, which are unit tested). This is a proportional, in-scope decision for this sprint (retroactively refactoring Sprint 6's already-shipped, already-smoke-verified move/resize/hit-test code was not part of either committed story), not an oversight, but it leaves a real, growing architectural inconsistency between the two element kinds' testability. | Unintentional (a natural side effect of adding new, better-architected code alongside older code that predates the pattern, not a deliberate call to leave the old code as-is) | Low (no known behavioral defect — the smoke-tested code paths work correctly per this sprint's live verification; purely a testability/maintainability gap, and the two newly-added rotation classes themselves are fully unit tested) | Open | Candidate direction: extract `TemplateCanvasControl`'s Address-Control-specific hit-test/drag/resize state and math into a new `AddressControlEditor` (Desktop.Core), mirroring `CanvasElementEditor`'s existing role for standalone elements, so all four interaction kinds (select, move, resize, rotate) are unit tested consistently. Not required by any currently-committed story; revisit if Address Control interaction logic grows further (e.g. multi-select epic 6 work) or a live-verified defect is found in it. **2026-10-27 update:** this row's own trigger occurred — Sprint 10 Batch 2 ("Select multiple elements at once on the canvas") added `AddressControlsInRect`/`ApplyMultiDragToAddressControls`/`ToggleAddressControlMultiSelect` directly to `TemplateCanvasControl`, following the same untested-WinForms-only pattern rather than doing the extraction, a deliberate choice (the standalone-element half of the exact same feature *is* fully unit tested in `CanvasElementEditor`, so multi-select's core logic has real test coverage even though the Address Control half doesn't) rather than scope-creeping an unrelated refactor into an already-large story. Still Open; the case for the extraction is now stronger with two features built on the untested side. |
+| 2026-10-27 | A standalone canvas element's selection highlight border (`TemplateCanvasControl.DrawElement`'s `isSelected` rectangle) visibly wraps only part of a multi-word `DisplayText` (e.g. the default "Static text" highlights only "Static") rather than the full rendered string, for every plain (non-boxed) element regardless of selection mode — discovered via a live built-form screenshot while verifying Sprint 10 Batch 2's multi-select highlighting, and confirmed pre-existing (present identically for the ordinary single-selection case, `MeasureElement`/`DrawElement`'s non-box branch is unmodified by Batch 2). Root cause not yet confirmed by code inspection beyond the immediate suspect: `MeasureElement`'s non-boxed path calls `Graphics.MeasureString(text, font)` (the no-layout-rectangle overload), a GDI+ API with a long-documented history of measuring narrower than what `Graphics.DrawString` actually renders for certain text/hint combinations — plausible but not yet isolated with a minimal repro. | Unintentional (a GDI+ measurement/render mismatch, not anything introduced by this sprint's own drawing changes) | Low (cosmetic, canvas-only design-time approximation — does not affect hit-testing correctness, which was unaffected in this session's own multi-select verification, nor the real PDF render path, which never calls this method) | Open | Not investigated further or fixed as part of Sprint 10 Batch 2 (out of scope for the multi-select story; affects the pre-existing single-selection highlight equally). Candidate direction: try `Graphics.MeasureString` with a generously large layout rectangle (rather than the no-rectangle overload) or `TextRenderer.MeasureText`, and compare against `DrawString`'s actual rendered extent on a real screenshot. Revisit if an operator reports the selection highlight looking wrong, or before any story that depends on this measurement being visually accurate (e.g. further canvas polish). |
diff --git a/state.md b/state.md
index 3bb2151..4ca046c 100644
--- a/state.md
+++ b/state.md
@@ -20,7 +20,9 @@
**Post-Sprint-10-Batch-1 user-reported regression fix (2026-10-27, handled outside formal ceremony, mid-Sprint-10):** User reported that clicking on and moving a dynamic placeholder had become "not smooth any more." Root cause found by direct `Stopwatch` instrumentation of `TemplateCanvasControl.OnMouseDown` (not guessed): `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 — a cost (~40-60ms/click, measured) that only became perceptible 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 (`RaiseSelectionChangedIfDifferent`). 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 unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix isolated to the WinForms-only `TemplateCanvasControl`). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`.
-**Next action:** Continue Sprint 10 as `dev-team`: Batch 2 "Select multiple elements at once on the canvas" (5 pts, epic 6, no dependency but sequenced next as the epic-6 pair's foundation), then Batch 3 "Align and distribute multiple elements" (5 pts, depends on Batch 2), per `backlog/sprints/sprint-10.md`. Key design note for Batch 2, not to be rediscovered mid-sprint: add multi-select as an *additive* layer alongside the existing single-selection state (`CanvasElementEditor.Selected` for standalone elements, `TemplateCanvasControl._selectedAddressControl` 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. This is a natural mid-sprint checkpoint (a large, first-of-its-kind feature remains, plus the human product owner just gave live-build feedback three times this session) — worth confirming with the user before continuing into the multi-select implementation. 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.
+**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.
## Phase reference
@@ -95,3 +97,4 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-10-27 | 2 - Sprint planning | `scrum-master` facilitated with `product-owner`/`dev-team` input. Capacity signal now has nine data points (20, 19, 18, 18, 15, 18, 18, 13, 13). Committed 13 points: the 3-point indicator story plus epic 6's multi-select/align pair (10 pts), finally pulled after being deferred twice (Sprint 8, Sprint 9) for fresher requests that no longer apply. Reasoned explicitly that pairing a small, additive story with one large/novel story (multi-select's new selection model) does not trip the established "two large/novel stories" caution, since only one side carries that risk profile. Recorded in `backlog/sprints/sprint-10.md`; full reasoning in `backlog/backlog.md`'s Sprint 10 planning outcome note. |
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 1, "Add a live wrap/clip indicator for text elements" (3/13 points). New framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel. Mid-batch, the human product owner raised two live-build issues, addressed together: (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely; `HasBox` now means "Width is set" alone, `Height` independent and optional (new `HasHeightClip`), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path. Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) confirmed auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`. |
| 2026-10-27 | 3 - Sprint execution (ad hoc, outside formal ceremony) | User reported that clicking on and moving a dynamic placeholder had become "not smooth any more." Root cause found by directly instrumenting `TemplateCanvasControl.OnMouseDown` with `Stopwatch` timers rather than guessing: `SelectionChanged` fired unconditionally on every left-click — including a click on an already-selected element, the normal way to begin dragging it — and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time, a cost (~40-60ms/click, measured) that only became perceptible once this sprint's added property-panel rows made the refresh heavier. Fixed by capturing the pre-click selection identity and only raising `SelectionChanged` when it actually changed (`RaiseSelectionChangedIfDifferent`). Live-verified with the same instrumented reflection harness: an already-selected element's second click dropped from ~50ms to 0.295ms end-to-end in `OnMouseDown`, while a genuine selection change still correctly pays the warranted refresh cost unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix isolated to the WinForms-only `TemplateCanvasControl`, which has no automated test project). Documented in `backlog/epics/02_template_designer_gui_foundation.md` and `backlog/sprints/sprint-10.md`. |
+| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 2, "Select multiple elements at once on the canvas" (5/13 points). Additive multi-select layer alongside existing single-selection state (`CanvasElementEditor.MultiSelected`, `TemplateCanvasControl._multiSelectedAddressControls`), collapsing to ordinary single-select at 0-1 items so every existing code path is unaffected. Rubber-band selection (rotation-aware AABB intersection) and modifier-click toggle both work across mixed elements/Address Controls; group drag moves the set by one shared delta preserving relative offsets (snapping the delta once, not each item's position independently); every selected item is visibly highlighted; properties panel shows "N items selected," disabled for per-item editing. 12 new Desktop.Core tests, full suite 364/364 -> 376/376. Live-verified via reflection-driven built-form harness including a real canvas screenshot; one disclosed caveat (`ModifierKeys` has no public setter, so modifier-click verified via direct private-method call) and one unrelated pre-existing cosmetic gap found and logged, not fixed in-scope (selection-highlight width under-wrap). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`. |