using System.Drawing.Drawing2D;
using EnvelopeRenderer.Desktop.Core.Design;
namespace EnvelopeRenderer.Desktop.Views;
///
/// The visual canvas surface (Sprint 2 Batch 3: "Place and move text elements on the canvas"):
/// draws the page and its text elements, and lets the operator select/drag-reposition them with
/// the mouse. Sprint 4 added: address-line collapse preview (so the canvas visually agrees with
/// what the CLI would render for a loaded CSV's sample data), rotation (drawing a rotated element
/// and hit-testing/dragging its rotate handle), and a mapping-error highlight for a dynamic
/// element bound to a column that isn't in the loaded CSV. All coordinate math and add/select/
/// drag/rotate state live in //
/// (all framework-free and unit tested in
/// EnvelopeRenderer.Desktop.Tests) — this class only does the GDI+ drawing and forwards mouse
/// events, since that part genuinely cannot be extracted from WinForms.
///
public sealed class TemplateCanvasControl : Control
{
private readonly TemplateLayoutDocument _document;
private readonly CanvasElementEditor _editor;
private AddressControlLayout? _selectedAddressControl;
private int _selectedAddressLineIndex;
private (double Dx, double Dy)? _addressControlDragOffset;
private bool _isResizingAddressControl;
/// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
/// mirrors 's shape — a control-specific drag-gesture
/// flag, paralleling (not reusing) , which only
/// ever operates on a selected standalone .
private bool _isRotatingAddressControl;
/// 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.
private IReadOnlyList _csvHeaders = Array.Empty();
private IReadOnlyDictionary? _csvSampleRecord;
public event EventHandler? SelectionChanged;
public event EventHandler? ElementsChanged;
public TemplateCanvasControl(TemplateLayoutDocument document)
{
_document = document;
_editor = new CanvasElementEditor(document, MeasureElement);
DoubleBuffered = true;
BackColor = SystemColors.ControlDark;
SetStyle(ControlStyles.ResizeRedraw, true);
}
/// Sprint 7, "Snap elements to grid and guides": mirrors
/// so the same toggle governs standalone
/// element dragging (handled inside ) and Address Control
/// move/resize (handled directly in this control's mouse handlers below) uniformly, and so
/// this control knows whether to paint grid lines.
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public bool SnapToGridEnabled
{
get => _editor.SnapToGridEnabled;
set
{
_editor.SnapToGridEnabled = value;
Invalidate();
}
}
/// Sprint 7: the grid increment (canvas-space points) snapping rounds to and grid
/// lines are painted at, when is true.
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public double GridSizePoints
{
get => _editor.GridSizePoints;
set
{
_editor.GridSizePoints = value > 0 ? value : CanvasElementEditor.DefaultGridSizePoints;
Invalidate();
}
}
/// True while an active move, resize, or rotate gesture is in progress on the
/// canvas — a standalone element drag/rotate () or an
/// Address Control move/resize/rotate. Post-Sprint-8 smoothness fix: lets
/// skip its full properties-panel refresh (which
/// repopulates the rebind-column combo box from every loaded CSV header on each call — real
/// work only for a single-column-bound dynamic element) on every mouse-move tick of a
/// gesture, syncing just the position/angle fields the gesture can actually change instead.
/// Mirrors the exact condition already used inline before this
/// property existed.
public bool IsInteracting =>
_editor.IsDragging || _editor.IsRotating || _editor.IsResizingFontSize || _addressControlDragOffset is not null
|| _isResizingAddressControl || _isRotatingAddressControl;
public TextElementLayout? SelectedElement => _editor.Selected;
public AddressControlLayout? SelectedAddressControl => _selectedAddressControl;
public int SelectedAddressLineIndex => _selectedAddressLineIndex;
public AddressControlLineLayout? SelectedAddressLine =>
_selectedAddressControl is not null
&& _selectedAddressLineIndex >= 0
&& _selectedAddressLineIndex < _selectedAddressControl.Lines.Count
? _selectedAddressControl.Lines[_selectedAddressLineIndex]
: null;
public TextElementLayout AddStaticTextElement()
{
var (x, y) = DefaultNewElementPosition();
var element = _editor.AddStaticText(x, y);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
return element;
}
public AddressControlLayout AddAddressControl()
{
var (x, y) = DefaultNewElementPosition();
var control = AddressControlLayout.CreateDefault(x, y, _document.NextZOrder());
_document.AddressControls.Add(control);
SelectAddressControl(control, 0);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
return control;
}
public void AddAddressLine()
{
if (_selectedAddressControl is null)
{
return;
}
_selectedAddressControl.AddLine();
_selectedAddressLineIndex = _selectedAddressControl.Lines.Count - 1;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
public void RemoveSelectedAddressLine()
{
if (_selectedAddressControl is null)
{
return;
}
if (_selectedAddressControl.RemoveLineAt(_selectedAddressLineIndex))
{
_selectedAddressLineIndex = Math.Min(_selectedAddressLineIndex, _selectedAddressControl.Lines.Count - 1);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
}
public void SelectAddressLine(int lineIndex)
{
if (_selectedAddressControl is null)
{
return;
}
_selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, _selectedAddressControl.Lines.Count - 1));
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
public void MoveSelectedAddressLineUp()
{
if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineUp(_selectedAddressLineIndex))
{
_selectedAddressLineIndex--;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
}
public void MoveSelectedAddressLineDown()
{
if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineDown(_selectedAddressLineIndex))
{
_selectedAddressLineIndex++;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
}
public TextElementLayout AddDynamicPlaceholderElement(string columnName = "Column")
{
var (x, y) = DefaultNewElementPosition();
var element = _editor.AddDynamicPlaceholder(x, y, columnName);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
return element;
}
/// Sprint 4: supplies the loaded CSV's headers and one representative sample record
/// (typically the first loaded sample row) so the canvas can preview address-line collapsing
/// and flag mapping errors exactly the way a real render would. Pass an empty header list and
/// null record to clear the preview context (e.g. nothing loaded yet).
public void SetCsvPreviewContext(IReadOnlyList headers, IReadOnlyDictionary? sampleRecord)
{
_csvHeaders = headers;
_csvSampleRecord = sampleRecord;
Invalidate();
}
/// Re-selects the given element (e.g. after the properties panel changes it) and
/// redraws — used so external edits stay visually in sync with the canvas.
public void NotifyElementChanged()
{
Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
/// Clears the current selection and redraws — used after reopening a saved template
/// (Batch 5), since a freshly loaded document's elements are new object instances and any
/// previously selected element instance no longer belongs to it.
public void ClearSelection()
{
_editor.Select(null);
_selectedAddressControl = null;
_selectedAddressLineIndex = 0;
_addressControlDragOffset = null;
_isResizingAddressControl = false;
_isRotatingAddressControl = false;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
private (double X, double Y) DefaultNewElementPosition()
{
// Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly
// on top of the last one.
var count = _document.Elements.Count + _document.AddressControls.Count;
var x = Math.Min(_document.Canvas.WidthPoints * 0.1 + (count * 10), _document.Canvas.WidthPoints - 20);
var y = Math.Max(_document.Canvas.HeightPoints * 0.8 - (count * 10), 10);
return (x, y);
}
private CanvasViewTransform CurrentTransform() =>
CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height);
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
var transform = CurrentTransform();
var (pageLeft, pageTop) = transform.ToPixels(0, _document.Canvas.HeightPoints);
var (pageRight, pageBottom) = transform.ToPixels(_document.Canvas.WidthPoints, 0);
var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom);
g.FillRectangle(Brushes.White, pageRect);
g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);
// Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so
// alignment is visible, not just felt during a drag — drawn under every element so it
// never obscures selection/rotate-handle/mapping-warning visuals.
if (SnapToGridEnabled)
{
DrawGrid(g, transform);
}
// Sprint 4: the same collapse-then-shift math the CLI's RenderEngine applies at render
// time, run here against the loaded CSV's sample record so the canvas preview and the
// final PDF agree (the story's "Preview and final render must agree" conversation note).
var previewStates = AddressBlockPreviewCalculator.Compute(_document.Elements, _csvHeaders, _csvSampleRecord);
var paintItems = new List<(int ZOrder, TextElementLayout? Text, AddressControlLayout? Control)>();
paintItems.AddRange(_document.Elements.Select(e => (e.ZOrder, Text: (TextElementLayout?)e, Control: (AddressControlLayout?)null)));
paintItems.AddRange(_document.AddressControls.Select(c => (c.ZOrder, Text: (TextElementLayout?)null, Control: (AddressControlLayout?)c)));
foreach (var item in paintItems.OrderBy(i => i.ZOrder))
{
if (item.Text is not null)
{
var state = previewStates[item.Text.Id];
if (!state.Visible)
{
continue;
}
DrawElement(g, transform, item.Text, state, isSelected: ReferenceEquals(item.Text, _editor.Selected));
continue;
}
DrawAddressControl(
g,
transform,
item.Control!,
isSelected: ReferenceEquals(item.Control, _selectedAddressControl));
}
if (_editor.Selected is not null)
{
DrawResizeHandle(g, transform, _editor.Selected);
DrawRotateHandle(g, transform, _editor.Selected);
}
}
/// Sprint 7: draws light dotted grid lines across the page at every
/// interval, in both directions, so an operator can see the
/// alignment grid snapping is rounding positions to.
private void DrawGrid(Graphics g, CanvasViewTransform transform)
{
var gridSize = GridSizePoints;
if (gridSize <= 0)
{
return;
}
using var gridPen = new Pen(System.Drawing.Color.FromArgb(110, System.Drawing.Color.SteelBlue), 1)
{
DashStyle = DashStyle.Dot,
};
for (var x = 0.0; x <= _document.Canvas.WidthPoints; x += gridSize)
{
var (x1, y1) = transform.ToPixels(x, 0);
var (x2, y2) = transform.ToPixels(x, _document.Canvas.HeightPoints);
g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
}
for (var y = 0.0; y <= _document.Canvas.HeightPoints; y += gridSize)
{
var (x1, y1) = transform.ToPixels(0, y);
var (x2, y2) = transform.ToPixels(_document.Canvas.WidthPoints, y);
g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
}
}
private void SelectAddressControl(AddressControlLayout control, int lineIndex)
{
_selectedAddressControl = control;
_selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, control.Lines.Count - 1));
_editor.Select(null);
}
private void ClearAddressSelection()
{
_selectedAddressControl = null;
_selectedAddressLineIndex = 0;
_addressControlDragOffset = null;
_isResizingAddressControl = false;
_isRotatingAddressControl = false;
}
private void DrawElement(
Graphics g, CanvasViewTransform transform, TextElementLayout element,
ElementPreviewState state, bool isSelected)
{
var effectiveY = state.EffectiveY;
using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
var (width, height) = MeasureElement(element);
// Element (X, effectiveY) is the bottom-left, baseline-ish origin in canvas space (points,
// bottom-left page origin); the drawn box spans up to (X + width, effectiveY + height), so
// the pixel position to draw the string's top-left corner at is the transform of
// (X, effectiveY + height). `effectiveY` is the same as `element.Y` unless Sprint 4's
// address-line collapsing has shifted it (see AddressBlockPreviewCalculator).
var (drawX, drawY) = transform.ToPixels(element.X, effectiveY + height);
GraphicsState? savedState = null;
if (element.RotationAngle != 0)
{
var (pivotX, pivotY) = RotationPivot(element, effectiveY, width, height);
var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
savedState = g.Save();
g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
// GDI+'s Graphics.RotateTransform is visually CLOCKWISE for a positive angle in this
// Y-down pixel space. The stored RotationAngle uses the opposite convention —
// counterclockwise-positive, confirmed empirically against the real Debenu DLL (see
// RotatedTextAnchorCalculator's class remarks in EnvelopeRenderer.Cli) — so the angle
// is negated here to keep the canvas rotating the same visual direction the final PDF
// will, per this story's "same rotation, around the same pivot, as shown in the
// designer canvas" acceptance criterion.
g.RotateTransform((float)-element.RotationAngle);
g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
}
try
{
var boxHeight = height * transform.Scale;
using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));
if (element.HasBox)
{
// Sprint 9, "Add an adjustable width and height with text wrapping...": GDI+'s own
// rectangle-bounded DrawString wraps natively within the box, the design-time
// approximation of the real CLI render's Debenu-native wrap (see this story's
// sizing note — the two engines have no shared line-breaking code path, so this is
// an accepted approximation, not a guarantee of matching the exact wrap points).
// The per-run highlight segmentation below is a single-line layout and does not
// yet extend across wrapped lines — left as a known, documented visual
// approximation gap for this story; a per-run-aware wrapped highlight is polish-
// tier scope for a later story, not required here.
var boxRect = new RectangleF((float)drawX, (float)drawY, (float)(width * transform.Scale), (float)boxHeight);
g.DrawString(element.DisplayText, font, brush, boxRect);
}
else
{
// Sprint 5, "Mix static text and CSV fields within a single text element", AC4: a
// visible placeholder highlight per field-run *segment* rather than one
// whole-element box — literal text within a mixed element gets no fill at all, so
// an operator can see exactly which portion(s) of the line are field tokens versus
// literal text. Sprint 4's unmapped-column warning color is now decided per run
// (see AddressBlockPreviewCalculator's per-run ElementPreviewState.UnmappedRuns)
// rather than for the whole element, so a mixed element with one bad token among
// several good ones only flags that one segment.
var segments = MeasureRunSegments(element, font);
var cumulativeWidth = 0.0;
for (var i = 0; i < segments.Count; i++)
{
var (run, segmentWidth) = segments[i];
if (run.IsField)
{
var isRunUnmapped = i < state.UnmappedRuns.Count && state.UnmappedRuns[i];
var fillColor = isRunUnmapped
? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
: System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
using var dynamicFill = new SolidBrush(fillColor);
var segX = drawX + (cumulativeWidth * transform.Scale);
var segBoxWidth = segmentWidth * transform.Scale;
g.FillRectangle(dynamicFill, (float)segX, (float)drawY, (float)segBoxWidth, (float)boxHeight);
}
cumulativeWidth += segmentWidth;
}
g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY);
}
if (state.IsUnmappedColumn)
{
using var warnPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
g.DrawRectangle(
warnPen, (float)drawX - 1, (float)drawY - 1,
(float)(width * transform.Scale) + 2, (float)(height * transform.Scale) + 2);
}
else if (element.CollapseIfBlank)
{
// A small marker for a configured-collapsible field, regardless of whether it
// happens to be blank right now — lets an operator see at a glance which fields
// are configured to collapse, without needing to select each one individually.
using var badgePen = new Pen(System.Drawing.Color.SeaGreen, 2);
var badgeY = (float)(drawY + (height * transform.Scale) + 2);
var badgeWidth = (float)Math.Min(width * transform.Scale, 16);
g.DrawLine(badgePen, (float)drawX, badgeY, (float)drawX + badgeWidth, badgeY);
}
if (isSelected)
{
var selWidth = width * transform.Scale;
var selHeight = height * transform.Scale;
using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
g.DrawRectangle(pen, (float)drawX - 2, (float)drawY - 2, (float)selWidth + 4, (float)selHeight + 4);
}
}
finally
{
if (savedState is not null)
{
g.Restore(savedState);
}
}
}
/// Post-Sprint-8 user-requested feature: draws the font-size resize handle at the
/// selected element's top-right corner (,
/// which already accounts for rotation) — a small square, the same MediumSeaGreen-fill/white-
/// outline style already used for the Address Control's resize handle, deliberately distinct
/// from the rotate handle's round dot so the two are never confused at a glance.
private void DrawResizeHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
{
var handle = _editor.ResizeHandlePosition();
if (handle is null)
{
return;
}
var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y);
var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
using var outline = new Pen(System.Drawing.Color.White, 1);
g.FillRectangle(brush, rect);
g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
}
/// Sprint 4, "Rotate elements by dragging a handle on the canvas": draws a small
/// dot connected to the selected element's bounding-box center by a dotted line, at the
/// world-space position computes (which
/// already accounts for the element's current rotation) — the same position
/// checks against, so what's drawn is exactly
/// what's draggable.
private void DrawRotateHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
{
var handle = _editor.HandlePosition();
if (handle is null)
{
return;
}
var (width, height) = MeasureElement(element);
var (pivotX, pivotY) = RotationPivot(element, element.Y, width, height);
var (centerPx, centerPy) = transform.ToPixels(pivotX, pivotY);
var (handlePx, handlePy) = transform.ToPixels(handle.Value.X, handle.Value.Y);
using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
const float radius = 5f;
using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
}
/// Sprint 8, "Rotate the whole Address Control as a single unit": when
/// is non-zero, the entire method body below
/// — the box border, every line's text and per-line highlight/selection overlay, and the
/// resize handle — is drawn under one GDI+ rotation transform around
/// , the same way already
/// rotates a standalone element's whole draw call. Because every pixel-space draw call inside
/// this method (box, lines, handle) shares that one transform, they all visually rotate
/// 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)
{
GraphicsState? savedState = null;
if (control.RotationAngle != 0)
{
var (pivotX, pivotY) = control.BoxCenter;
var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
savedState = g.Save();
g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
// See DrawElement's remarks: GDI+'s RotateTransform is visually clockwise-positive in
// this Y-down pixel space, the opposite of this project's counterclockwise-positive
// stored convention, hence the negation.
g.RotateTransform((float)-control.RotationAngle);
g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
}
try
{
DrawAddressControlUnrotated(g, transform, control, isSelected);
}
finally
{
if (savedState is not null)
{
g.Restore(savedState);
}
}
}
private void DrawAddressControlUnrotated(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected)
{
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);
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)
{
DrawAddressResizeHandle(g, transform, control);
// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
// drawn here, in the control's own local (unrotated) coordinates, so it automatically
// rotates together with the box/lines via DrawAddressControl's enclosing GDI+
// transform — the same reason the resize handle above already orbits correctly.
DrawAddressRotateHandle(g, transform, control);
}
for (var i = 0; i < control.Lines.Count; i++)
{
if (!lineStates[i].Visible)
{
continue;
}
var line = control.Lines[i];
using var font = ResolveFont(line.FontFamily, (float)line.FontSize);
var text = ResolveAddressLinePreviewText(line) ?? line.DisplayText;
var size = MeasureText(text, font);
var (drawX, drawY) = transform.ToPixels(control.X, lineStates[i].EffectiveY + size.Height);
if (line.IsDynamic)
{
using var fill = new SolidBrush(System.Drawing.Color.FromArgb(45, System.Drawing.Color.DodgerBlue));
g.FillRectangle(
fill,
(float)drawX,
(float)drawY,
(float)Math.Min(control.Width * transform.Scale, Math.Max(6, size.Width * transform.Scale)),
(float)(size.Height * transform.Scale));
}
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)
{
using var linePen = new Pen(System.Drawing.Color.MediumSeaGreen, 1);
g.DrawRectangle(
linePen,
(float)drawX - 2,
(float)drawY - 2,
(float)(Math.Max(size.Width, control.Width) * transform.Scale) + 4,
(float)(size.Height * transform.Scale) + 4);
}
}
}
private static double MaxLineFontSize(AddressControlLayout control) =>
control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize);
private static void DrawAddressResizeHandle(
Graphics g, CanvasViewTransform transform, AddressControlLayout control)
{
var handle = AddressResizeHandleCenter(control);
var (handleX, handleY) = transform.ToPixels(handle.X, handle.Y);
var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
using var outline = new Pen(System.Drawing.Color.White, 1);
g.FillRectangle(brush, rect);
g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
}
private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control)
{
var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
return (control.X + control.Width, bottom + ((top - bottom) / 2.0));
}
/// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
/// paints the drag handle for a selected Address Control, in the same visual style
/// (SeaGreen dot, dashed connector line, white outline) as the standalone-element rotate
/// handle () for consistency — the recommended default per this
/// story's own notes, absent a strong reason to diverge. Position math lives in the
/// framework-free, unit-tested (mirroring how
/// holds the standalone-element equivalent); drawn here in
/// local (unrotated) coordinates, which is sufficient to make it visually orbit with the
/// control's own rotation via 's enclosing GDI+ transform —
/// the same reason the resize handle above already orbits correctly.
private static void DrawAddressRotateHandle(Graphics g, CanvasViewTransform transform, AddressControlLayout control)
{
var (centerX, topY) = AddressControlRotateHandle.LocalOrigin(control);
var (handleX, handleY) = AddressControlRotateHandle.LocalPosition(control);
var (centerPx, centerPy) = transform.ToPixels(centerX, topY);
var (handlePx, handlePy) = transform.ToPixels(handleX, handleY);
using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
const float radius = 5f;
using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
}
private AddressLineCollapser.Resolved[] ComputeAddressControlLineStates(AddressControlLayout control)
{
var lines = new AddressLineCollapser.Line[control.Lines.Count];
for (var i = 0; i < control.Lines.Count; i++)
{
var line = control.Lines[i];
var resolvedText = ResolveAddressLinePreviewText(line);
var shouldCollapse = line.CollapseIfBlank && resolvedText is not null && string.IsNullOrWhiteSpace(resolvedText);
lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse);
}
return AddressLineCollapser.Resolve(lines).ToArray();
}
/// Sprint 7: delegates to the shared (also used by
/// and the new preview panel) instead of keeping
/// its own identical copy of this per-run resolution rule.
private string? ResolveAddressLinePreviewText(AddressControlLineLayout line) =>
TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord);
/// Sprint 6 defect fix (record-accurate render/preview only, not this canvas — see
/// below): static text rotates around its measured center, while dynamic/mixed content rotates
/// around its fixed authored anchor so record-to-record text-width changes cannot move the
/// pivot. Post-Sprint-7-review fix (2026-10-19): this editing canvas only ever draws an
/// element's literal `{ColumnName}` token text, never a per-record resolved value (see
/// 's remarks), so the dynamic/
/// mixed-content drift the anchor-pivot branch guards against cannot happen here — this method
/// now always takes the bounding-box-center path for every element, static or not, so a
/// dynamic/mixed element's rotate-handle drag spins in place like static text instead of
/// swinging around a corner. The real render (RotatedTextAnchorCalculator/
/// DebenuPdfRenderer) and the new preview panel (TemplatePreviewControl/
/// TemplatePreviewBuilder) are unaffected — they still call
/// directly with the real isDynamic
/// value.
private static (double X, double Y) RotationPivot(
TextElementLayout element, double effectiveY, double width, double height) =>
RotationPivotCalculator.ComputeForCanvasEditing(element.X, effectiveY, width, height);
/// Measures an element's rendered size in canvas-space points, using a
/// measuring context so the result is directly comparable to
/// the point-based coordinates stores — this is a design-time
/// visual approximation of the real Debenu-rendered size, not a guarantee of pixel-for-point
/// parity with the final PDF. Sprint 9, "Add an adjustable width and height with text
/// wrapping...": once an element has a box (), its size
/// *is* the box — authored geometry, not something to (re-)measure from text — so every
/// downstream consumer of this method (hit-testing, the rotate handle, the resize handle, and
/// itself) automatically treats the box as the element's real
/// bounding box with no changes needed at those call sites.
private static (double Width, double Height) MeasureElement(TextElementLayout element)
{
if (element.HasBox)
{
return (element.Width!.Value, element.Height!.Value);
}
using var bitmap = new Bitmap(1, 1);
using var g = Graphics.FromImage(bitmap);
g.PageUnit = GraphicsUnit.Point;
using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
var size = g.MeasureString(text, font);
return (size.Width, size.Height);
}
private static (double Width, double Height) MeasureText(string text, Font font)
{
using var bitmap = new Bitmap(1, 1);
using var g = Graphics.FromImage(bitmap);
g.PageUnit = GraphicsUnit.Point;
var size = g.MeasureString(string.IsNullOrEmpty(text) ? " " : text, font);
return (size.Width, size.Height);
}
/// Sprint 5, AC4: measures each run's own display segment width in canvas-space
/// points (same measuring context as ,
/// for consistency), so can position a per-run highlight fill at the
/// right cumulative offset. Like , this is a design-time visual
/// approximation — GDI+'s per-segment measurement summed this way does not necessarily equal
/// its measurement of the whole concatenated string to the last fraction of a point (kerning/
/// spacing metrics are not strictly additive), which is an accepted, documented limitation of
/// a canvas preview rather than a rendering-affecting one (the CLI's real render always draws
/// one already-concatenated string, never per-run pieces).
private static IReadOnlyList<(TextRun Run, double Width)> MeasureRunSegments(TextElementLayout element, Font font)
{
using var bitmap = new Bitmap(1, 1);
using var g = Graphics.FromImage(bitmap);
g.PageUnit = GraphicsUnit.Point;
var segments = TextRunTextConverter.ToDisplaySegments(element.Runs);
var result = new List<(TextRun, double)>(segments.Count);
foreach (var (run, text) in segments)
{
var width = text.Length == 0 ? 0.0 : g.MeasureString(text, font).Width;
result.Add((run, width));
}
return result;
}
/// Falls back to a generic sans-serif font if the requested family isn't installed,
/// so a missing font only affects the design-time preview's appearance — it does not crash
/// the designer. This is independent of, and does not relax, the render-time rule that a
/// missing font is a blocking error (`TEMPLATE_FORMAT.md`'s "Known gaps").
private static Font ResolveFont(string familyName, float size)
{
try
{
return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point);
}
catch (ArgumentException)
{
return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point);
}
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (e.Button != MouseButtons.Left)
{
return;
}
var transform = CurrentTransform();
var (x, y) = transform.ToPoints(e.X, e.Y);
if (_selectedAddressControl is not null && HitTestAddressResizeHandle(_selectedAddressControl, x, y, transform))
{
_isResizingAddressControl = true;
Capture = true;
Invalidate();
return;
}
// Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": checked
// right alongside the resize-handle check above (same precedence rule: only meaningful,
// and only checked, when this control is already selected) so a rotate-handle grab is
// never confused with a normal select/move click elsewhere on the control.
if (_selectedAddressControl is not null && AddressControlRotateHandle.HitTest(_selectedAddressControl, x, y))
{
_isRotatingAddressControl = true;
Capture = true;
Invalidate();
return;
}
var addressHit = HitTestAddressControl(x, y);
if (addressHit.Control is not null)
{
SelectAddressControl(addressHit.Control, addressHit.LineIndex);
_addressControlDragOffset = (x - addressHit.Control.X, y - addressHit.Control.Y);
Capture = true;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
return;
}
ClearAddressSelection();
// Post-Sprint-8 user-requested feature: a click on the selected element's font-size
// resize handle starts a resize-drag, checked with the same precedence as the rotate
// handle immediately below (only meaningful, and only checked, once something is already
// selected) so it is never confused with a normal select/move click.
if (_editor.Selected is not null && _editor.HitTestResizeHandle(x, y))
{
_editor.BeginResizeDrag();
Capture = true;
Invalidate();
return;
}
// Sprint 4: a click on the currently selected element's rotate handle starts a rotate-drag
// instead of a normal select/move — checked first (and only when something is already
// selected) so it never intercepts a normal click elsewhere on the canvas.
if (_editor.Selected is not null && _editor.HitTestHandle(x, y))
{
_editor.BeginRotateDrag();
Capture = true;
Invalidate();
return;
}
var hit = _editor.TrySelectAt(x, y);
if (hit)
{
_editor.BeginDrag(x, y);
Capture = true;
}
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
/// Sprint 8: a rotated control must remain correctly click-selectable at its actual
/// rotated position, not its unrotated bounding box — ported from
/// CanvasElementEditor.IsPointInRotatedBounds's approach: rotate the click point
/// backward (by -RotationAngle) around the same
/// pivot rotates around, landing it back in the control's
/// unrotated local space, then run the exact same plain-rectangle/line test as before. An
/// unrotated control (the overwhelmingly common case) takes the cheap direct path.
private (AddressControlLayout? Control, int LineIndex) HitTestAddressControl(double xPoints, double yPoints)
{
foreach (var control in _document.AddressControls.OrderByDescending(c => c.ZOrder))
{
var (testX, testY) = control.RotationAngle == 0
? (xPoints, yPoints)
: PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
var top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top)
{
continue;
}
var lineIndex = 0;
for (var i = 0; i < control.Lines.Count; i++)
{
var baseline = control.BaselineYForLine(i);
var lineTop = baseline + control.Lines[i].FontSize;
var nextBaseline = i == control.Lines.Count - 1
? bottom
: control.BaselineYForLine(i + 1);
if (testY <= lineTop && testY >= nextBaseline)
{
lineIndex = i;
break;
}
}
return (control, lineIndex);
}
return (null, 0);
}
/// Sprint 8: same rotate-the-click-point-backward approach as
/// — the resize handle is drawn (via
/// ) inside 's rotation
/// transform, so it visually orbits with the rotated box; the hit test rotates the click point
/// back into the same unrotated local space before comparing against the handle's unrotated
/// position.
private static bool HitTestAddressResizeHandle(
AddressControlLayout control, double xPoints, double yPoints, CanvasViewTransform transform)
{
const double handleTolerancePixels = 8;
var tolerancePoints = handleTolerancePixels / transform.Scale;
var (testX, testY) = control.RotationAngle == 0
? (xPoints, yPoints)
: PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
var handle = AddressResizeHandleCenter(control);
return Math.Abs(testX - handle.X) <= tolerancePoints
&& Math.Abs(testY - handle.Y) <= tolerancePoints;
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if (!IsInteracting)
{
return;
}
var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
if (_selectedAddressControl is not null && _isRotatingAddressControl)
{
AddressControlRotateHandle.RotateDragTo(_selectedAddressControl, x, y);
Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
return;
}
if (_selectedAddressControl is not null && _isResizingAddressControl)
{
// Sprint 8: rotate the drag point backward into the control's unrotated local space
// first (same as the hit test above) so resizing a rotated control still tracks the
// mouse along the box's own local width axis, not the world X axis.
var (localX, _) = _selectedAddressControl.RotationAngle == 0
? (x, y)
: PointRotation.RotateAroundPivot(x, y, _selectedAddressControl.BoxCenter, -_selectedAddressControl.RotationAngle);
var width = Math.Max(1, localX - _selectedAddressControl.X);
_selectedAddressControl.Width = SnapToGridEnabled ? Math.Max(1, GridSnapper.Snap(width, GridSizePoints)) : width;
Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
return;
}
if (_selectedAddressControl is not null && _addressControlDragOffset is not null)
{
var newX = x - _addressControlDragOffset.Value.Dx;
var newY = y - _addressControlDragOffset.Value.Dy;
if (SnapToGridEnabled)
{
newX = GridSnapper.Snap(newX, GridSizePoints);
newY = GridSnapper.Snap(newY, GridSizePoints);
}
_selectedAddressControl.X = newX;
_selectedAddressControl.Y = newY;
Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
return;
}
if (_editor.IsResizingFontSize)
{
_editor.ResizeDragTo(x, y);
}
else if (_editor.IsRotating)
{
_editor.RotateDragTo(x, y);
}
else
{
_editor.DragTo(x, y);
}
Invalidate();
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
var wasInteracting = IsInteracting;
_editor.EndDrag();
_addressControlDragOffset = null;
_isResizingAddressControl = false;
_isRotatingAddressControl = false;
Capture = false;
// The gesture's own per-tick ElementsChanged notifications (OnMouseMove) only ask
// TemplateDesignerForm for a cheap position/angle-only sync (see IsInteracting's
// remarks) — fire one last notification now that the gesture has actually ended so the
// form's full properties-panel refresh (rebind combo, address line list, etc.) still
// runs exactly once at the end, the same as it always did before this smoothness fix.
if (wasInteracting)
{
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
}
}