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`. |