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;
/// 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);
}
public TextElementLayout? SelectedElement => _editor.Selected;
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 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);
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;
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 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);
foreach (var element in _document.Elements.OrderBy(el => el.ZOrder))
{
var state = previewStates[element.Id];
if (!state.Visible)
{
continue;
}
DrawElement(g, transform, element, state.EffectiveY, state.IsUnmappedColumn,
isSelected: ReferenceEquals(element, _editor.Selected));
}
if (_editor.Selected is not null)
{
DrawRotateHandle(g, transform, _editor.Selected);
}
}
private void DrawElement(
Graphics g, CanvasViewTransform transform, TextElementLayout element,
double effectiveY, bool isUnmappedColumn, bool isSelected)
{
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 (centerXPx, centerYPx) = transform.ToPixels(element.X + (width / 2.0), effectiveY + (height / 2.0));
savedState = g.Save();
g.TranslateTransform((float)centerXPx, (float)centerYPx);
// 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)-centerXPx, (float)-centerYPx);
}
try
{
if (element.IsDynamic)
{
// A visible placeholder representation distinct from static text (Sprint 3 Batch 3
// acceptance criterion). Sprint 4: an unmapped column (bound to a name that isn't
// among the currently loaded CSV's headers — a real mapping problem) gets a
// visually distinct warning color instead of the normal "this is dynamic" blue, so
// an operator can tell a mapping error apart from a field that's merely blank for
// the current sample data (which draws with the ordinary blue fill, or is skipped
// entirely if also collapsible — see the caller's `Visible` check).
var boxWidth = width * transform.Scale;
var boxHeight = height * transform.Scale;
var fillColor = isUnmappedColumn
? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
: System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
using var dynamicFill = new SolidBrush(fillColor);
g.FillRectangle(dynamicFill, (float)drawX, (float)drawY, (float)boxWidth, (float)boxHeight);
}
using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));
g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY);
if (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);
}
}
}
/// 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 (centerPx, centerPy) = transform.ToPixels(element.X + (width / 2.0), element.Y + (height / 2.0));
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);
}
/// 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.
private static (double Width, double Height) MeasureElement(TextElementLayout element)
{
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);
}
/// 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 (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
// 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);
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if (!_editor.IsDragging && !_editor.IsRotating)
{
return;
}
var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
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);
_editor.EndDrag();
Capture = false;
}
}