Adds an additive multi-selection layer alongside the existing single-select state for both standalone elements and Address Controls: rubber-band drag, Ctrl/Shift-click toggle, group drag preserving relative offsets, per-item highlight, and a disabled "N items selected" properties panel. Collapses back to ordinary single-select whenever 0-1 items remain selected so every existing code path is unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -10,7 +10,7 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i | |||
| | 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) | | |||
| | 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 | |||
| @@ -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 | |||
| @@ -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) <br> - [x] Draw a small visual indicator (dashed-orange-style, consistent with the existing unmapped-column warning) on `TemplateCanvasControl` and `TemplatePreviewControl` when either condition is true for the current sample record, drawn inside the existing per-element rotation transform so it rotates for free <br> - [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free) <br> - [x] Live built-form smoke: a narrow (120pt) boxed element's real canvas screenshot shows both genuine multi-line wrap and the dashed-orange indicator appearing correctly | | |||
| | Select multiple elements at once on the canvas | 5 points | 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 <br> - [ ] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release <br> - [ ] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest <br> - [ ] Visible selection indication for every selected item, not just the primary one <br> - [ ] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning) <br> - [ ] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes) <br> - [ ] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core) <br> - [ ] Live built-form smoke (touches the properties panel's multi-item behavior) | | |||
| | Select multiple elements at once on the canvas | 5 points | Done | - [x] Add a multi-select data structure alongside the existing single-selection state (`CanvasElementEditor` for standalone elements, `TemplateCanvasControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected <br> - [x] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release <br> - [x] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest <br> - [x] Visible selection indication for every selected item, not just the primary one <br> - [x] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning) <br> - [x] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes) <br> - [x] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core) <br> - [x] Live built-form smoke (touches the properties panel's multi-item behavior) | | |||
| | Align and distribute multiple elements | 5 points | Not Started | - [ ] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height) <br> - [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection <br> - [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise <br> - [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free) <br> - [ ] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) | | |||
| ## 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<TextElementLayout>`) alongside the existing `Selected` field, and a parallel `TemplateCanvasControl._multiSelectedAddressControls` for Address Controls, with a `CollapseMultiSelectionIfSingular` rule ensuring every existing single-select code path keeps working unchanged whenever 0 or 1 items end up selected. Rubber-band selection (`CanvasElementEditor.ElementsInRect`, rotation-aware via rotated-corner AABB, plus a parallel `TemplateCanvasControl.AddressControlsInRect`) and modifier-click toggle (`ToggleMultiSelect`, seeding from the current single selection) both replace or extend the multi-selection on release/click; group drag (`BeginMultiDrag`/`MultiDragTo`, snapping the shared *delta* once rather than each item's position independently) moves the whole set together preserving relative offsets; `DrawAddressControl` was split into separate `isPrimarySelected` (handles) and `isMultiSelected` (highlight-only) parameters so every selected item is visibly marked without ambiguous multi-item handles; the properties panel shows "N items selected" and disables per-item editing (Delete disabled too, rather than extended to a bulk operation — out of this story's scope). Tests: +12 (Desktop.Core, including a rotated-element `ElementsInRect` case and a snap-to-grid `MultiDragTo` case). Full suite 364/364 -> 376/376 (Desktop.Core; CLI unaffected at 124/124). Live-verified with a reflection-driven harness against the real built form: rubber-band selected exactly the 2 of 3 elements it should; the panel showed the disabled "2 items selected." state; toggling a third item in and the first back out produced the exact expected membership; a group drag moved two elements by an identical measured delta with their relative offset exactly unchanged and the unselected third element untouched; clicking empty space cleared the selection; a real canvas screenshot confirmed every multi-selected item shows the highlight border. One disclosed evidence-depth caveat: `Control.ModifierKeys` has no public setter and queries live keyboard state, so modifier-click was verified by calling the same private toggle method the click handler delegates to, directly, rather than through a real simulated key-press. One pre-existing, unrelated cosmetic gap found (not fixed) during verification: a plain element's selection highlight border visibly wraps only part of a multi-word display string — confirmed present identically for ordinary single-selection, logged to `logs/technical_debt_log.md` (2026-10-27) rather than fixed in-scope. | Batch 3, "Align and distribute multiple elements" (5 points, epic 6, depends on Batch 2's multi-selection now existing). | None. | | |||
| @@ -37,6 +37,17 @@ public sealed class CanvasElementEditor | |||
| private bool _isRotating; | |||
| private bool _isResizingWidth; | |||
| /// <summary>Sprint 10, "Select multiple elements at once on the canvas": an *additive* layer | |||
| /// alongside <see cref="Selected"/>, not a replacement — every existing single-select code | |||
| /// path (properties panel, rotate/resize handles, line drill-in) keeps reading | |||
| /// <see cref="Selected"/> unchanged. <see cref="TemplateCanvasControl"/> is responsible for the | |||
| /// "collapse to single-select when exactly one item ends up selected, clear <see cref="Selected"/> | |||
| /// 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.</summary> | |||
| private readonly HashSet<TextElementLayout> _multiSelected = new(); | |||
| private (double X, double Y)? _multiDragGrabPoint; | |||
| private Dictionary<TextElementLayout, (double X, double Y)>? _multiDragOriginalPositions; | |||
| public CanvasElementEditor( | |||
| TemplateLayoutDocument document, Func<TextElementLayout, (double Width, double Height)> measureText) | |||
| { | |||
| @@ -148,6 +159,145 @@ public sealed class CanvasElementEditor | |||
| public void Select(TextElementLayout? element) => Selected = element; | |||
| /// <summary>Sprint 10: the standalone elements currently part of the multi-selection — see | |||
| /// this field's own remarks for the collapse rule <see cref="TemplateCanvasControl"/> | |||
| /// applies.</summary> | |||
| public IReadOnlySet<TextElementLayout> MultiSelected => _multiSelected; | |||
| /// <summary>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).</summary> | |||
| public void SetMultiSelection(IEnumerable<TextElementLayout> elements) | |||
| { | |||
| _multiSelected.Clear(); | |||
| foreach (var element in elements) | |||
| { | |||
| _multiSelected.Add(element); | |||
| } | |||
| } | |||
| /// <summary>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.</summary> | |||
| public void ToggleMultiSelect(TextElementLayout element) | |||
| { | |||
| if (!_multiSelected.Remove(element)) | |||
| { | |||
| _multiSelected.Add(element); | |||
| } | |||
| } | |||
| public void ClearMultiSelect() => _multiSelected.Clear(); | |||
| /// <summary>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 <see cref="IsPointInRotatedBounds"/>'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.</summary> | |||
| public IReadOnlyList<TextElementLayout> ElementsInRect(double minX, double minY, double maxX, double maxY) | |||
| { | |||
| var result = new List<TextElementLayout>(); | |||
| 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); | |||
| } | |||
| /// <summary>Starts a group drag of every element currently in <see cref="MultiSelected"/>, | |||
| /// capturing each one's original position so <see cref="MultiDragTo"/> can move the whole set | |||
| /// by one shared delta — preserving every item's relative offset, matching how | |||
| /// <see cref="BeginDrag"/>/<see cref="DragTo"/> preserve a single element's grab offset. | |||
| /// No-op if nothing is multi-selected.</summary> | |||
| 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)); | |||
| } | |||
| /// <summary>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 | |||
| /// <see cref="SnapToGridEnabled"/>, 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.</summary> | |||
| 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; | |||
| /// <summary>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.</summary> | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -30,6 +30,27 @@ public sealed class TemplateCanvasControl : Control | |||
| /// ever operates on a selected standalone <see cref="TextElementLayout"/>.</summary> | |||
| private bool _isRotatingAddressControl; | |||
| /// <summary>Sprint 10, "Select multiple elements at once on the canvas": the Address Control | |||
| /// half of the multi-selection — mirrors <see cref="CanvasElementEditor.MultiSelected"/> for | |||
| /// standalone elements, since <see cref="CanvasElementEditor"/> has no knowledge of Address | |||
| /// Controls at all (the same split as single-selection's <see cref="_selectedAddressControl"/> | |||
| /// vs. <c>CanvasElementEditor.Selected</c>). 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 <see cref="CollapseMultiSelectionIfSingular"/>.</summary> | |||
| private readonly HashSet<AddressControlLayout> _multiSelectedAddressControls = new(); | |||
| private bool _isRubberBandSelecting; | |||
| private bool _rubberBandAdditive; | |||
| private (double X, double Y)? _rubberBandStart; | |||
| private (double X, double Y)? _rubberBandCurrent; | |||
| /// <summary>Grab point and original positions for the Address Control half of an in-progress | |||
| /// group drag — mirrors <see cref="CanvasElementEditor.BeginMultiDrag"/>/<c>MultiDragTo</c> for | |||
| /// standalone elements, applied to both halves from the same shared delta every tick so the | |||
| /// whole mixed multi-selection moves together.</summary> | |||
| private (double X, double Y)? _multiDragGrabPoint; | |||
| private Dictionary<AddressControlLayout, (double X, double Y)>? _multiDragOriginalAddressPositions; | |||
| /// <summary>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/<c>null</c> until <see cref="SetCsvPreviewContext"/> is called after a CSV loads.</summary> | |||
| @@ -95,11 +116,24 @@ public sealed class TemplateCanvasControl : Control | |||
| /// property existed.</summary> | |||
| 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; | |||
| /// <summary>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 <see cref="IsMultiSelectionActive"/> and | |||
| /// <see cref="CollapseMultiSelectionIfSingular"/> for why a single leftover item always | |||
| /// collapses back into the ordinary single-selection fields instead of staying here.</summary> | |||
| public int MultiSelectionCount => _editor.MultiSelected.Count + _multiSelectedAddressControls.Count; | |||
| /// <summary>True while two or more items (any mix of standalone elements and Address Controls) | |||
| /// are selected together. <see cref="TemplateDesignerForm"/> uses this to show "N items | |||
| /// selected" and disable per-item property editing instead of displaying stale/misleading | |||
| /// single-item values.</summary> | |||
| 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); | |||
| } | |||
| } | |||
| /// <summary>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).</summary> | |||
| 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); | |||
| } | |||
| /// <summary>Sprint 7: draws light dotted grid lines across the page at every | |||
| @@ -391,6 +461,234 @@ public sealed class TemplateCanvasControl : Control | |||
| _isRotatingAddressControl = false; | |||
| } | |||
| /// <summary>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.</summary> | |||
| private void ClearMultiSelection() | |||
| { | |||
| _editor.ClearMultiSelect(); | |||
| _multiSelectedAddressControls.Clear(); | |||
| } | |||
| /// <summary>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 (<see cref="CanvasElementEditor.Selected"/> | |||
| /// or <see cref="_selectedAddressControl"/>) 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.</summary> | |||
| 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(); | |||
| } | |||
| /// <summary>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.</summary> | |||
| 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); | |||
| } | |||
| } | |||
| /// <summary>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 <see cref="CanvasElementEditor.ElementsInRect"/>, kept here rather | |||
| /// than in Desktop.Core since it needs <see cref="MaxLineFontSize"/>, the same WinForms-only | |||
| /// geometry helper <see cref="DrawAddressControlUnrotated"/> already uses.</summary> | |||
| private List<AddressControlLayout> AddressControlsInRect(double minX, double minY, double maxX, double maxY) | |||
| { | |||
| var result = new List<AddressControlLayout>(); | |||
| 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; | |||
| } | |||
| /// <summary>Finalizes a rubber-band drag on release: computes the final (normalized) rectangle, | |||
| /// finds every element/control it intersects, and either replaces the multi-selection with that | |||
| /// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag, | |||
| /// <see cref="_rubberBandAdditive"/>) — mirroring modifier-click's "extend, don't replace" | |||
| /// behavior for consistency.</summary> | |||
| 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<TextElementLayout>(_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 <see cref="HitTestAddressControl"/> and | |||
| /// <see cref="HitTestAddressResizeHandle"/> independently confirm by rotating the click point | |||
| /// back into this same unrotated local space before testing.</summary> | |||
| 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) | |||
| /// <summary>Sprint 10, "Select multiple elements at once on the canvas": <paramref | |||
| /// name="isPrimarySelected"/> and <paramref name="isMultiSelected"/> 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).</summary> | |||
| 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; | |||
| @@ -1045,6 +1045,18 @@ public sealed class TemplateDesignerForm : Form | |||
| /// properties-panel edit never disagree about the element's current values.</summary> | |||
| 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) | |||
| { | |||
| @@ -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). | | |||
| @@ -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`. | | |||
Powered by TurnKey Linux.