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;
/// Sprint 10, "Select multiple elements at once on the canvas": the Address Control
/// half of the multi-selection — mirrors for
/// standalone elements, since has no knowledge of Address
/// Controls at all (the same split as single-selection's
/// vs. CanvasElementEditor.Selected). An *additive* layer: every existing single-select
/// code path keeps working unchanged whenever this set (plus the editor's) totals fewer than
/// two items — see .
private readonly HashSet _multiSelectedAddressControls = new();
private bool _isRubberBandSelecting;
private bool _rubberBandAdditive;
private (double X, double Y)? _rubberBandStart;
private (double X, double Y)? _rubberBandCurrent;
/// Grab point and original positions for the Address Control half of an in-progress
/// group drag — mirrors /MultiDragTo for
/// standalone elements, applied to both halves from the same shared delta every tick so the
/// whole mixed multi-selection moves together.
private (double X, double Y)? _multiDragGrabPoint;
private Dictionary? _multiDragOriginalAddressPositions;
/// The currently loaded CSV's headers and one representative sample record, used
/// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) —
/// empty/null until is called after a CSV loads.
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;
AllowDrop = true;
BackColor = SystemColors.ControlDark;
SetStyle(ControlStyles.ResizeRedraw, true);
// Post-Sprint-9: a plain Control (unlike UserControl) is not selectable/focusable by
// default, so it never received key events at all — needed for the Delete key to remove
// the selected element (see OnKeyDown below and RemoveSelectedElement's own remarks on
// the reported gap this fixes).
SetStyle(ControlStyles.Selectable, true);
TabStop = 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.IsResizing || _addressControlDragOffset is not null
|| _isResizingAddressControl || _isRotatingAddressControl || _editor.IsMultiDragging;
public TextElementLayout? SelectedElement => _editor.Selected;
public AddressControlLayout? SelectedAddressControl => _selectedAddressControl;
public int SelectedAddressLineIndex => _selectedAddressLineIndex;
/// Sprint 10, "Select multiple elements at once on the canvas": the combined size of
/// the multi-selection across both standalone elements and Address Controls. Zero or one means
/// no *active* multi-selection — see and
/// for why a single leftover item always
/// collapses back into the ordinary single-selection fields instead of staying here.
public int MultiSelectionCount => _editor.MultiSelected.Count + _multiSelectedAddressControls.Count;
/// True while two or more items (any mix of standalone elements and Address Controls)
/// are selected together. uses this to show "N items
/// selected" and disable per-item property editing instead of displaying stale/misleading
/// single-item values.
public bool IsMultiSelectionActive => MultiSelectionCount >= 2;
public AddressControlLineLayout? SelectedAddressLine =>
_selectedAddressControl is not null
&& _selectedAddressLineIndex >= 0
&& _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;
}
/// Adds a filled rectangle (a text element with an empty content, a box and a fill
/// color) that covers whatever is under it and can have text typed into it. White by default,
/// like a cover-up patch.
public TextElementLayout AddRectangleElement()
{
var (x, y) = DefaultNewElementPosition();
var element = _editor.AddStaticText(x, y, text: string.Empty);
element.Width = DefaultRectangleWidth;
element.Height = DefaultRectangleHeight;
element.FillColor = new RgbColor(255, 255, 255);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
return element;
}
public const double DefaultRectangleWidth = 120;
public const double DefaultRectangleHeight = 40;
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);
}
}
/// Drag-and-drop data format carrying a CSV column header name from the form's CSV
/// grid onto this canvas.
public const string ColumnDragFormat = "EnvelopeRenderer.CsvColumn";
/// Drops a CSV column onto the canvas at a client-space pixel position: onto an
/// Address Control it appends a new line bound to the column; anywhere else it places a new
/// dynamic placeholder there. Ignored in show-data (read-only) mode.
public bool DropColumn(string columnName, Point clientPoint)
{
if (_showData || string.IsNullOrEmpty(columnName))
{
return false;
}
var (x, y) = CurrentTransform().ToPoints(clientPoint.X, clientPoint.Y);
var (control, _) = HitTestAddressControl(x, y);
if (control is not null)
{
control.Lines.Add(AddressControlLineLayout.CreateField(columnName));
SelectAddressControl(control, control.Lines.Count - 1);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
_editor.AddDynamicPlaceholder(x, y, columnName);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
ElementsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
protected override void OnDragOver(DragEventArgs drgevent)
{
base.OnDragOver(drgevent);
drgevent.Effect = !_showData && drgevent.Data?.GetDataPresent(ColumnDragFormat) == true
? DragDropEffects.Copy
: DragDropEffects.None;
}
protected override void OnDragDrop(DragEventArgs drgevent)
{
base.OnDragDrop(drgevent);
if (drgevent.Data?.GetData(ColumnDragFormat) is string column)
{
DropColumn(column, PointToClient(new Point(drgevent.X, drgevent.Y)));
}
}
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();
}
private bool _showData;
/// "Show data" view mode: the canvas draws each element's text resolved against the
/// current record (the one supplied via ) instead of the
/// literal {Column} tokens with their blue placeholder fills. Read-only while on — hit
/// testing and measuring work from the token text, so editing gestures are ignored until the
/// operator switches back to design view.
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public bool ShowData
{
get => _showData;
set
{
if (_showData == value)
{
return;
}
_showData = value;
Invalidate();
}
}
/// The text an element is drawn with: its resolved value for the current record in
/// show-data mode (falling back to the token text if it cannot be resolved), otherwise the
/// token text.
private string DisplayTextFor(TextElementLayout element) =>
_showData
? TextResolver.TryResolve(element.Runs, _csvHeaders, _csvSampleRecord) ?? element.DisplayText
: element.DisplayText;
/// 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);
_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);
}
/// Post-Sprint-9 user-reported gap: there was no way to delete a placed element or
/// Address Control at all once added. Removes whichever is currently selected (a standalone
/// element or a whole Address Control — never just a drilled-into address line, which has its
/// own dedicated ) and clears the selection. Returns
/// false (a no-op) if nothing is selected.
public bool RemoveSelectedElement()
{
if (_selectedAddressControl is not null)
{
var removed = _document.AddressControls.Remove(_selectedAddressControl);
ClearSelection();
if (removed)
{
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
return removed;
}
if (_editor.Selected is not null)
{
var removed = _editor.RemoveSelected();
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
if (removed)
{
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
return removed;
}
return false;
}
/// Sprint 10, "Align and distribute multiple elements": aligns every item in the
/// current multi-selection to the given of the selection's combined
/// bounding box. A no-op below 2 selected items — callers (the alignment toolbar buttons) are
/// expected to disable themselves via rather than relying on
/// this guard alone, per this story's "clearly disabled, not a silent no-op" AC.
public void AlignSelection(AlignmentCalculator.Edge edge)
{
if (MultiSelectionCount < 2)
{
return;
}
ApplySelectionDeltas(AlignmentCalculator.ComputeAlignmentDeltas(GetSelectionBounds(out var elements, out var controls), edge), elements, controls);
}
/// Distributes the current multi-selection with equal center-to-center spacing along
/// the given — see
/// for the documented MVP center-spacing simplification. A no-op below 2 selected items (and,
/// per that method's own remarks, has no visible effect below 3, since there is nothing to
/// place between the two extreme items).
public void DistributeSelection(AlignmentCalculator.Axis axis)
{
if (MultiSelectionCount < 2)
{
return;
}
ApplySelectionDeltas(AlignmentCalculator.ComputeDistributionDeltas(GetSelectionBounds(out var elements, out var controls), axis), elements, controls);
}
/// Builds the combined, fixed-order bounding-box list
/// operates on — standalone elements first, then Address Controls — so a returned delta list
/// can be zipped back to the exact item it applies to via /
/// ' matching order.
private List<(double MinX, double MinY, double MaxX, double MaxY)> GetSelectionBounds(
out List elements, out List controls)
{
elements = _editor.MultiSelected.ToList();
controls = _multiSelectedAddressControls.ToList();
var bounds = new List<(double MinX, double MinY, double MaxX, double MaxY)>(elements.Count + controls.Count);
bounds.AddRange(elements.Select(_editor.GetWorldBounds));
bounds.AddRange(controls.Select(GetAddressControlWorldBounds));
return bounds;
}
private void ApplySelectionDeltas(
IReadOnlyList<(double Dx, double Dy)> deltas, List elements, List controls)
{
for (var i = 0; i < elements.Count; i++)
{
elements[i].X += deltas[i].Dx;
elements[i].Y += deltas[i].Dy;
}
for (var i = 0; i < controls.Count; i++)
{
var delta = deltas[elements.Count + i];
controls[i].X += delta.Dx;
controls[i].Y += delta.Dy;
}
Invalidate();
ElementsChanged?.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);
/// Folder relative background paths resolve against (the template folder).
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public string? BackgroundBaseDirectory { get; set; }
private string? _backgroundImagePath;
private DateTime _backgroundImageStamp;
private Image? _backgroundImage;
/// Paints the template page background under everything else. Images are drawn
/// stretched to the page (matching the render). A PDF cannot be rasterized by GDI+, so the page
/// shows a labelled placeholder - the real PDF page appears in the rendered output.
private void DrawPageBackground(Graphics g, RectangleF pageRect)
{
var background = _document.Background;
if (background is null)
{
return;
}
var file = background.ResolveFile(_csvSampleRecord, BackgroundBaseDirectory);
if (file is null)
{
return;
}
if (PageBackgroundLayout.IsPdf(file))
{
var page = background.ResolvePage(_csvSampleRecord);
var pdfBitmap = page is { } pageNumber ? LoadPdfBackground(file, pageNumber, (int)pageRect.Width, out var pdfError) : null;
if (pdfBitmap is not null)
{
g.DrawImage(pdfBitmap, pageRect);
return;
}
using var tint = new SolidBrush(System.Drawing.Color.FromArgb(235, 240, 250));
g.FillRectangle(tint, pageRect);
using var font = new Font("Segoe UI", 9f);
g.DrawString(
$"PDF background: {Path.GetFileName(file)}, page {(page?.ToString() ?? "?")}\n(could not be shown here: {_pdfBackgroundError ?? "invalid page"})",
font, Brushes.SlateGray, pageRect.X + 6, pageRect.Y + 6);
return;
}
var image = LoadBackgroundImage(file);
if (image is not null)
{
g.DrawImage(image, pageRect);
}
}
private string? _pdfBackgroundKey;
private int _pdfBackgroundWidth;
private Bitmap? _pdfBackgroundBitmap;
private string? _pdfBackgroundError;
/// The PDF page rendered with Debenu DARenderPageToDC, cached per (file, page, file
/// timestamp) and re-rendered only when the canvas page width changes by more than ~25% (zoom),
/// so a repaint during drag or selection never re-renders the PDF.
private Bitmap? LoadPdfBackground(string path, int page, int pageWidthPixels, out string? error)
{
var stamp = File.Exists(path) ? File.GetLastWriteTimeUtc(path).Ticks : 0;
var key = $"{path}|{page}|{stamp}";
var wanted = Math.Max(pageWidthPixels, 50);
var sameKey = _pdfBackgroundKey == key;
var sizeOk = _pdfBackgroundWidth > 0 && wanted <= _pdfBackgroundWidth * 1.25 && wanted >= _pdfBackgroundWidth * 0.75;
if (sameKey && (_pdfBackgroundBitmap is null ? _pdfBackgroundError is not null : sizeOk || _pdfBackgroundWidth >= PdfPageBitmapRenderer.MaxPixelWidth))
{
error = _pdfBackgroundError;
return _pdfBackgroundBitmap;
}
var rendered = PdfPageBitmapRenderer.Render(path, page, wanted, out var renderError);
_pdfBackgroundBitmap?.Dispose();
_pdfBackgroundBitmap = rendered;
_pdfBackgroundKey = key;
_pdfBackgroundWidth = rendered is null ? 0 : wanted;
_pdfBackgroundError = renderError;
error = renderError;
return rendered;
}
private Image? LoadBackgroundImage(string path)
{
try
{
var stamp = File.GetLastWriteTimeUtc(path);
if (_backgroundImage is not null && _backgroundImagePath == path && _backgroundImageStamp == stamp)
{
return _backgroundImage;
}
_backgroundImage?.Dispose();
_backgroundImage = null;
_backgroundImagePath = path;
_backgroundImageStamp = stamp;
// Copy through a MemoryStream so the file is not left locked by GDI+.
using var stream = new MemoryStream(File.ReadAllBytes(path));
using var loaded = Image.FromStream(stream);
_backgroundImage = new Bitmap(loaded);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OutOfMemoryException)
{
_backgroundImage = null;
}
return _backgroundImage;
}
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);
DrawPageBackground(g, 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;
}
// 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;
}
DrawAddressControl(
g,
transform,
item.Control!,
isPrimarySelected: ReferenceEquals(item.Control, _selectedAddressControl),
isMultiSelected: _multiSelectedAddressControls.Contains(item.Control!));
}
if (_editor.Selected is not null)
{
DrawResizeHandle(g, transform, _editor.Selected);
DrawHeightHandle(g, transform);
DrawRotateHandle(g, transform, _editor.Selected);
}
if (_isRubberBandSelecting && _rubberBandStart is not null && _rubberBandCurrent is not null)
{
DrawRubberBand(g, transform, _rubberBandStart.Value, _rubberBandCurrent.Value);
}
}
/// Sprint 10, "Select multiple elements at once on the canvas": the marquee rectangle
/// drawn while a rubber-band selection drag is in progress, in canvas-page-agnostic pixel
/// space (drawn last, on top of everything else, the same way a rotate/resize handle already
/// draws on top of its element).
private static void DrawRubberBand(
Graphics g, CanvasViewTransform transform, (double X, double Y) start, (double X, double Y) current)
{
var (x1, y1) = transform.ToPixels(start.X, start.Y);
var (x2, y2) = transform.ToPixels(current.X, current.Y);
var rect = RectangleF.FromLTRB(
(float)Math.Min(x1, x2), (float)Math.Min(y1, y2), (float)Math.Max(x1, x2), (float)Math.Max(y1, y2));
using var fill = new SolidBrush(System.Drawing.Color.FromArgb(40, System.Drawing.Color.DodgerBlue));
using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
g.FillRectangle(fill, rect);
g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
}
/// Sprint 7: draws light dotted grid lines across the page at every
/// 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;
}
/// Sprint 10, "Select multiple elements at once on the canvas": empties both halves
/// of the multi-selection (standalone elements and Address Controls) without touching the
/// ordinary single-selection fields — callers that are about to establish a fresh single
/// selection call this first so a stale multi-selection never lingers alongside it.
private void ClearMultiSelection()
{
_editor.ClearMultiSelect();
_multiSelectedAddressControls.Clear();
}
/// Applies the multi-selection's one collapse rule after every mutation (a rubber-band
/// release, a modifier-click toggle): zero items means nothing is selected at all; exactly one
/// item collapses back into the ordinary single-selection fields (
/// or ) so every existing single-select code path —
/// properties panel, rotate/resize handles, address-line drill-in — keeps working completely
/// unchanged; two or more items clears both single-selection fields so the properties panel
/// shows "N items selected" instead of stale single-item values.
private void CollapseMultiSelectionIfSingular()
{
var multiElements = _editor.MultiSelected;
var total = multiElements.Count + _multiSelectedAddressControls.Count;
if (total == 0)
{
_editor.Select(null);
ClearAddressSelection();
return;
}
if (total == 1)
{
if (multiElements.Count == 1)
{
var onlyElement = multiElements.First();
_editor.ClearMultiSelect();
_editor.Select(onlyElement);
ClearAddressSelection();
}
else
{
var onlyControl = _multiSelectedAddressControls.First();
_multiSelectedAddressControls.Clear();
SelectAddressControl(onlyControl, 0);
}
return;
}
// Two or more: neither single-selection field applies while a multi-selection is active.
_editor.Select(null);
ClearAddressSelection();
}
/// Modifier-click (Ctrl/Shift) support: toggles the given element's membership in the
/// multi-selection. If nothing was multi-selected yet, first seeds the set with whatever was
/// singly selected — the standard modifier-click UX extends the current selection rather than
/// starting over from empty.
private void ToggleElementMultiSelect(TextElementLayout element)
{
SeedMultiSelectionFromSingleSelectionIfEmpty();
_editor.ToggleMultiSelect(element);
CollapseMultiSelectionIfSingular();
}
private void ToggleAddressControlMultiSelect(AddressControlLayout control)
{
SeedMultiSelectionFromSingleSelectionIfEmpty();
if (!_multiSelectedAddressControls.Remove(control))
{
_multiSelectedAddressControls.Add(control);
}
CollapseMultiSelectionIfSingular();
}
private void SeedMultiSelectionFromSingleSelectionIfEmpty()
{
if (_editor.MultiSelected.Count > 0 || _multiSelectedAddressControls.Count > 0)
{
return;
}
if (_editor.Selected is not null)
{
_editor.SetMultiSelection(new[] { _editor.Selected });
}
if (_selectedAddressControl is not null)
{
_multiSelectedAddressControls.Add(_selectedAddressControl);
}
}
/// Rubber-band selection support: every Address Control whose world-space
/// (rotation-aware) axis-aligned bounding box intersects the given rectangle — the Address
/// Control counterpart of , kept here rather
/// than in Desktop.Core since it needs , the same WinForms-only
/// geometry helper already uses.
private List AddressControlsInRect(double minX, double minY, double maxX, double maxY)
{
var result = new List();
foreach (var control in _document.AddressControls)
{
var (elMinX, elMinY, elMaxX, elMaxY) = GetAddressControlWorldBounds(control);
if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
{
result.Add(control);
}
}
return result;
}
/// Sprint 10, "Align and distribute multiple elements": the same rotation-aware
/// world-space AABB already computed inline for rubber-band
/// hit-testing, extracted so alignment/distribution can feed it into
/// alongside
/// for standalone elements.
private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control)
{
var top = control.TopBaselineY + MaxLineFontSize(control);
var bottom = control.TopBaselineY - control.Height;
var left = control.X;
var right = control.X + control.Width;
if (control.RotationAngle == 0)
{
return (left, bottom, right, top);
}
var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) };
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) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle);
minX = Math.Min(minX, rx);
maxX = Math.Max(maxX, rx);
minY = Math.Min(minY, ry);
maxY = Math.Max(maxY, ry);
}
return (minX, minY, maxX, maxY);
}
/// Finalizes a rubber-band drag on release: computes the final (normalized) rectangle,
/// finds every element/control it intersects, and either replaces the multi-selection with that
/// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag,
/// ) — mirroring modifier-click's "extend, don't replace"
/// behavior for consistency.
private void FinalizeRubberBandSelection()
{
if (_rubberBandStart is not null && _rubberBandCurrent is not null)
{
var (x1, y1) = _rubberBandStart.Value;
var (x2, y2) = _rubberBandCurrent.Value;
var minX = Math.Min(x1, x2);
var maxX = Math.Max(x1, x2);
var minY = Math.Min(y1, y2);
var maxY = Math.Max(y1, y2);
var hitElements = _editor.ElementsInRect(minX, minY, maxX, maxY);
var hitControls = AddressControlsInRect(minX, minY, maxX, maxY);
if (_rubberBandAdditive)
{
var combinedElements = new HashSet(_editor.MultiSelected);
foreach (var element in hitElements)
{
combinedElements.Add(element);
}
_editor.SetMultiSelection(combinedElements);
foreach (var control in hitControls)
{
_multiSelectedAddressControls.Add(control);
}
}
else
{
_editor.SetMultiSelection(hitElements);
_multiSelectedAddressControls.Clear();
foreach (var control in hitControls)
{
_multiSelectedAddressControls.Add(control);
}
}
CollapseMultiSelectionIfSingular();
}
_isRubberBandSelecting = false;
_rubberBandStart = null;
_rubberBandCurrent = null;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
private void BeginGroupDrag(double grabXPoints, double grabYPoints)
{
_editor.BeginMultiDrag(grabXPoints, grabYPoints);
_multiDragGrabPoint = (grabXPoints, grabYPoints);
_multiDragOriginalAddressPositions = _multiSelectedAddressControls.ToDictionary(c => c, c => (c.X, c.Y));
}
private void ApplyMultiDragToAddressControls(double xPoints, double yPoints)
{
if (_multiDragGrabPoint is null || _multiDragOriginalAddressPositions is null)
{
return;
}
var dx = xPoints - _multiDragGrabPoint.Value.X;
var dy = yPoints - _multiDragGrabPoint.Value.Y;
if (SnapToGridEnabled)
{
dx = GridSnapper.Snap(dx, GridSizePoints);
dy = GridSnapper.Snap(dy, GridSizePoints);
}
foreach (var (control, original) in _multiDragOriginalAddressPositions)
{
control.X = original.X + dx;
control.Y = original.Y + dy;
}
}
private void EndGroupDrag()
{
_editor.EndMultiDrag();
_multiDragGrabPoint = null;
_multiDragOriginalAddressPositions = null;
}
private void DrawElement(
Graphics g, CanvasViewTransform transform, TextElementLayout element,
ElementPreviewState state, bool isSelected)
{
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, boxTopY) = transform.ToPixels(element.X, effectiveY + height);
using var drawFont = ResolveDrawFont(element.FontFamily, (float)element.FontSize, transform.Scale);
// Plain (non-box) text is positioned by baseline like the PDF; a boxed element's text
// hangs from the top of its box (PDF DrawTextBox).
var drawY = element.HasBox
? boxTopY
: transform.ToPixels(element.X, effectiveY).Y - BaselineOffsetPx(drawFont);
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.HasFill && element.FillColor is { } rectFill)
{
// Filled rectangle: painted first so the element's own text (and any element with
// a higher z-order) draws over it, like the real render.
using var fillBrush = new SolidBrush(System.Drawing.Color.FromArgb(rectFill.R, rectFill.G, rectFill.B));
g.FillRectangle(fillBrush, (float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight);
// Design-view outline so a white (or page-coloured) rectangle is still visible and
// clickable; drawn only on the canvas, never in the rendered PDF. A dark and a light
// dashed line together stay visible over any fill or background.
var outlineRect = new RectangleF((float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight);
using var darkPen = new Pen(System.Drawing.Color.FromArgb(150, 90, 90, 90), 1f);
using var lightPen = new Pen(System.Drawing.Color.White, 1f) { DashStyle = DashStyle.Dash };
g.DrawRectangle(darkPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height);
g.DrawRectangle(lightPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height);
}
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(DisplayTextFor(element), drawFont, brush, boxRect);
// Sprint 10, "Add a live wrap/clip indicator for text elements": a dashed-orange
// outline, the same visual language as the unmapped-column warning below, drawn
// whenever the element's *current* content is actually wrapping and/or being
// clipped — reusing the same box-mode text this method just drew, not a second
// divergent measurement.
var (isWrapping, isClipping) = MeasureWrapClip(element, font);
if (isWrapping || isClipping)
{
using var wrapClipPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
g.DrawRectangle(
wrapClipPen, (float)drawX - 1, (float)drawY - 1,
(float)(width * transform.Scale) + 2, (float)boxHeight + 2);
}
}
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 = _showData ? new List<(TextRun Run, double Width)>() : 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(DisplayTextFor(element), drawFont, 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 DrawHeightHandle(Graphics g, CanvasViewTransform transform)
{
var handle = _editor.HeightHandlePosition();
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);
}
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 isPrimarySelected, bool isMultiSelected)
{
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, isPrimarySelected, isMultiSelected);
}
finally
{
if (savedState is not null)
{
g.Restore(savedState);
}
}
}
/// Sprint 10, "Select multiple elements at once on the canvas": and are deliberately separate
/// — both draw the same highlighted border (so every selected item is visibly marked), but
/// only the primary selection gets the resize/rotate handles and the drilled-into-line
/// highlight, so a multi-selected control never shows handles that would be ambiguous about
/// which item they act on (out of scope for this story; align/distribute operates on the whole
/// set instead).
private void DrawAddressControlUnrotated(
Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected)
{
var isHighlighted = isPrimarySelected || isMultiSelected;
var lineStates = ComputeAddressControlLineStates(control);
using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash };
using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1);
var (left, top) = transform.ToPixels(control.X, control.TopBaselineY + MaxLineFontSize(control));
var (right, bottom) = transform.ToPixels(control.X + control.Width, control.TopBaselineY - control.Height);
var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom);
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":
// 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 = control.TextCase.Apply(ResolveAddressLinePreviewText(line) ?? line.DisplayText);
var size = MeasureText(text, font);
using var drawFont = ResolveDrawFont(line.FontFamily, (float)line.FontSize, transform.Scale);
var drawX = transform.ToPixels(control.X, lineStates[i].EffectiveY).X;
var drawY = transform.ToPixels(control.X, lineStates[i].EffectiveY).Y - BaselineOffsetPx(drawFont);
if (line.IsDynamic && !_showData)
{
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, drawFont, brush, (float)drawX, (float)drawY);
if (isPrimarySelected && 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.TopBaselineY + MaxLineFontSize(control);
var bottom = control.TopBaselineY - 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);
}
var resolved = AddressLineCollapser.Resolve(lines).ToArray();
if (control.VerticalAnchor == AddressVerticalAnchor.Bottom)
{
var shift = AddressLineCollapser.BottomAnchorShift(resolved, control.Y);
for (var i = 0; i < resolved.Length; i++)
{
resolved[i] = resolved[i] with { EffectiveY = resolved[i].EffectiveY + shift };
}
}
return resolved;
}
/// 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) =>
_showData ? TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord) : null;
/// 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/10, "Add an adjustable width and height with text
/// wrapping...": once an element has a box ( — Width
/// alone, post-Sprint-9), its width is the authored box width, not something to (re-)measure
/// from text; its height is the explicit clip ceiling when
/// is set, or (when not) the natural wrapped height GDI+ reports for this exact width/text/font
/// — never a fixed clip in that case. Every downstream consumer of this method (hit-testing,
/// the rotate handle, the resize handle, and itself) automatically
/// treats this 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)
{
using var boxFont = ResolveFont(element.FontFamily, (float)element.FontSize);
var effectiveHeight = element.HasHeightClip
? element.Height!.Value
: MeasureWrappedHeight(element.DisplayText, boxFont, element.Width!.Value);
return (element.Width!.Value, effectiveHeight);
}
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);
}
/// Sprint 9/10: the height GDI+'s own rectangle-bounded layout needs to wrap
/// to points — the auto-height a boxed
/// element with no explicit clip ceiling uses, mirroring
/// how the CLI asks Debenu's own GetWrappedTextHeight for the equivalent value.
private static double MeasureWrappedHeight(string text, Font font, double width)
{
using var bitmap = new Bitmap(1, 1);
using var g = Graphics.FromImage(bitmap);
g.PageUnit = GraphicsUnit.Point;
var displayText = string.IsNullOrEmpty(text) ? " " : text;
var size = g.MeasureString(displayText, font, Math.Max(1, (int)Math.Round(width)));
return size.Height;
}
/// Sprint 10, "Add a live wrap/clip indicator for text elements": compares the
/// element's natural (unconstrained) width and GDI+-wrapped height against its box to decide
/// whether the *current* content is actually wrapping and/or being clipped right now — pure
/// arithmetic lives in ; this just does the GDI+ measurement
/// that decision needs.
private static (bool IsWrapping, bool IsClipping) MeasureWrapClip(TextElementLayout element, Font font)
{
using var bitmap = new Bitmap(1, 1);
using var g = Graphics.FromImage(bitmap);
g.PageUnit = GraphicsUnit.Point;
var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
var naturalWidth = g.MeasureString(text, font).Width;
var wrappedHeight = MeasureWrappedHeight(text, font, element.Width!.Value);
// No explicit clip ceiling means "no clip" by definition (see HasHeightClip's remarks) —
// never flag clipping in that case, regardless of how tall the wrapped content grows.
var boxHeight = element.HasHeightClip ? element.Height!.Value : double.PositiveInfinity;
return WrapClipDetector.Detect(naturalWidth, wrappedHeight, element.Width!.Value, boxHeight);
}
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").
/// The font used to actually paint text: sized in pixels (point size times the
/// canvas zoom) so glyphs scale with the page exactly like positions and boxes do. A
/// points-unit font would be converted at the screen DPI regardless of zoom, so text would
/// not track the PDF's size. Measurement helpers keep using (points).
private static Font ResolveDrawFont(string familyName, float sizePoints, double scale)
{
var px = (float)(Math.Max(sizePoints <= 0 ? 12f : sizePoints, 0.1f) * scale);
try
{
return new Font(familyName, Math.Max(px, 1f), FontStyle.Regular, GraphicsUnit.Pixel);
}
catch (ArgumentException)
{
return new Font(FontFamily.GenericSansSerif, Math.Max(px, 1f), FontStyle.Regular, GraphicsUnit.Pixel);
}
}
/// Distance in pixels from the top of a GDI+ text box to the baseline for a
/// pixel-unit font. The PDF places text by baseline (the element's Y), so the canvas draws the
/// box this far above the baseline.
private static float BaselineOffsetPx(Font pixelFont)
{
var family = pixelFont.FontFamily;
var style = pixelFont.Style;
return pixelFont.Size * family.GetCellAscent(style) / family.GetEmHeight(style);
}
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);
// Post-Sprint-9: needed so this control actually receives OnKeyDown (Delete key) at all —
// see the constructor's ControlStyles.Selectable remarks.
Focus();
if (e.Button != MouseButtons.Left || _showData)
{
return;
}
// Post-Sprint-10 user-reported regression fix: SelectionChanged used to fire unconditionally
// on every left-click, including a click on an item that was ALREADY selected (the normal
// way to begin dragging it). TemplateDesignerForm's SelectionChanged handler runs the full,
// non-trivial RefreshPropertiesPanel() (combo-box rebind repopulation etc.) with no
// IsInteracting-style gating the way the drag/rotate/resize ElementsChanged path has, so
// every single click paid that cost — a real, measured ~40-60ms hitch per click once this
// session's added property-panel rows (box width/height, delete button) made the refresh
// heavy enough to notice. Capturing the pre-click selection identity and only raising the
// event when it actually changed makes "click an already-selected element to move it" free
// again while still notifying on every real selection change.
var previousSelectedElement = _editor.Selected;
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);
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)
{
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;
Invalidate();
RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
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.HitTestHeightHandle(x, y))
{
_editor.BeginHeightResizeDrag();
Capture = true;
Invalidate();
return;
}
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 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);
}
/// Raises only when the selection identity captured
/// before a click actually differs from the current one — see 's
/// remarks for why an unconditional raise on every click was a real, measured hitch.
private void RaiseSelectionChangedIfDifferent(
TextElementLayout? previousElement, AddressControlLayout? previousAddressControl, int previousAddressLineIndex)
{
if (!ReferenceEquals(previousElement, _editor.Selected)
|| !ReferenceEquals(previousAddressControl, _selectedAddressControl)
|| previousAddressLineIndex != _selectedAddressLineIndex)
{
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.TopBaselineY + MaxLineFontSize(control);
var bottom = control.TopBaselineY - 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);
// 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;
}
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);
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.IsResizing)
{
_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);
if (_isRubberBandSelecting)
{
Capture = false;
FinalizeRubberBandSelection();
return;
}
var wasInteracting = IsInteracting;
_editor.EndDrag();
EndGroupDrag();
_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);
}
}
/// Post-Sprint-9 user-reported gap: Delete/Backspace removes whichever element or
/// Address Control is currently selected — there was no way to delete a placed item at all
/// before this. Only fires while not mid-gesture, so an accidental key press during a drag
/// can't delete out from under an in-progress move/resize/rotate.
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if ((e.KeyCode is Keys.Delete or Keys.Back) && !IsInteracting && !_showData)
{
RemoveSelectedElement();
e.Handled = true;
}
else if (e.KeyCode == Keys.Tab && !e.Control && !e.Alt)
{
// Tab = next item, Shift+Tab = previous (z-order, wrapping) - reaches items hidden
// behind other items. Ctrl+Tab still leaves the canvas as usual.
SelectNextItem(forward: !e.Shift);
e.Handled = true;
e.SuppressKeyPress = true;
}
}
/// Selects the next (or previous) item in z-order, so an item hidden behind another
/// one can be reached without clicking it. Wraps at both ends. Returns false when there
/// is nothing to select or the canvas is showing data (selection is disabled then).
public bool SelectNextItem(bool forward)
{
if (_showData || IsInteracting)
{
return false;
}
var items = _document.Elements
.Select(e => new SelectionCycler.Item(e.Id, e.ZOrder, e.X, e.Y))
.Concat(_document.AddressControls.Select(c => new SelectionCycler.Item(c.Id, c.ZOrder, c.X, c.Y)))
.ToList();
Guid? current = _editor.Selected?.Id ?? _selectedAddressControl?.Id;
if (SelectionCycler.Next(items, current, forward) is not { } nextId)
{
return false;
}
ClearMultiSelection();
ClearAddressSelection();
var element = _document.Elements.FirstOrDefault(e => e.Id == nextId);
if (element is not null)
{
_editor.Select(element);
}
else if (_document.AddressControls.FirstOrDefault(c => c.Id == nextId) is { } control)
{
SelectAddressControl(control, 0);
}
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
return true;
}
protected override bool IsInputKey(Keys keyData) =>
keyData is Keys.Delete or Keys.Back
|| ((keyData & ~Keys.Shift) == Keys.Tab)
|| base.IsInputKey(keyData);
}