From d59ab47bc29e347ff478c8d47832e75112a5762f Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Tue, 29 Sep 2026 16:48:15 -0400 Subject: [PATCH] Designer: Show data toggle, column drag-drop, line spacing, font picker, canvas text fidelity - Replace the side Preview panel with a Show data toggle and record picker on the element toolbar; the canvas shows resolved values (read-only) or the {Column} tokens for editing, including Address Control lines. - Drag a CSV column header onto the canvas to place a field, or onto an Address Control to add a bound line. - Add a Line spacing field for Address Controls. - Font is chosen through the standard Windows font dialog (family and size). - Canvas text now scales with zoom and sits on the element's baseline, so size and position match the PDF. Co-Authored-By: Claude Sonnet 5.5 --- .../Views/TemplateCanvasControl.cs | 137 ++++++++++- .../Views/TemplateDesignerForm.cs | 215 ++++++++++-------- 2 files changed, 252 insertions(+), 100 deletions(-) diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs index a258e8b..1737d27 100644 --- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs +++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs @@ -66,6 +66,7 @@ public sealed class TemplateCanvasControl : Control _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 @@ -227,6 +228,56 @@ public sealed class TemplateCanvasControl : Control } } + /// 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(); @@ -248,6 +299,37 @@ public sealed class TemplateCanvasControl : Control 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() @@ -776,7 +858,13 @@ public sealed class TemplateCanvasControl : Control // 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); + 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) @@ -813,7 +901,7 @@ public sealed class TemplateCanvasControl : Control // 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); + 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 @@ -839,7 +927,7 @@ public sealed class TemplateCanvasControl : Control // (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 segments = _showData ? new List<(TextRun Run, double Width)>() : MeasureRunSegments(element, font); var cumulativeWidth = 0.0; for (var i = 0; i < segments.Count; i++) { @@ -859,7 +947,7 @@ public sealed class TemplateCanvasControl : Control cumulativeWidth += segmentWidth; } - g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY); + g.DrawString(DisplayTextFor(element), drawFont, brush, (float)drawX, (float)drawY); } if (state.IsUnmappedColumn) @@ -1027,9 +1115,11 @@ public sealed class TemplateCanvasControl : Control 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); + 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) + if (line.IsDynamic && !_showData) { using var fill = new SolidBrush(System.Drawing.Color.FromArgb(45, System.Drawing.Color.DodgerBlue)); g.FillRectangle( @@ -1041,7 +1131,7 @@ public sealed class TemplateCanvasControl : Control } using var brush = new SolidBrush(System.Drawing.Color.FromArgb(line.Color.R, line.Color.G, line.Color.B)); - g.DrawString(text, font, brush, (float)drawX, (float)drawY); + g.DrawString(text, drawFont, brush, (float)drawX, (float)drawY); if (isPrimarySelected && i == _selectedAddressLineIndex) { @@ -1123,7 +1213,7 @@ public sealed class TemplateCanvasControl : Control /// 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); + _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 @@ -1248,6 +1338,33 @@ public sealed class TemplateCanvasControl : Control /// 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 @@ -1266,7 +1383,7 @@ public sealed class TemplateCanvasControl : Control // 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) + if (e.Button != MouseButtons.Left || _showData) { return; } @@ -1618,7 +1735,7 @@ public sealed class TemplateCanvasControl : Control protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); - if ((e.KeyCode is Keys.Delete or Keys.Back) && !IsInteracting) + if ((e.KeyCode is Keys.Delete or Keys.Back) && !IsInteracting && !_showData) { RemoveSelectedElement(); e.Handled = true; diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs index b8efbde..bcf9e43 100644 --- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs +++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs @@ -139,12 +139,15 @@ public sealed class TemplateDesignerForm : Form private readonly NumericUpDown _xInput = new() { DecimalPlaces = 2, Minimum = 0, Maximum = 100000m, Width = 80 }; private readonly NumericUpDown _yInput = new() { DecimalPlaces = 2, Minimum = 0, Maximum = 100000m, Width = 80 }; private readonly NumericUpDown _addressWidthInput = new() { DecimalPlaces = 2, Minimum = 1, Maximum = 100000m, Width = 80 }; + // Gap between Address Control lines as a multiple of the previous line's font size + // (AddressControlLayout.LineSpacingMultiplier, default 1.25); below 1.0 packs lines tighter. + private readonly NumericUpDown _addressLineSpacingInput = new() { DecimalPlaces = 2, Increment = 0.05m, Minimum = 0.5m, Maximum = 5m, Width = 80 }; private readonly ListBox _addressLineList = new() { Width = 210, Height = 72, IntegralHeight = false }; private readonly Button _addAddressLineButton = new() { Text = "+", Width = 34, Height = 24 }; private readonly Button _removeAddressLineButton = new() { Text = "-", Width = 34, Height = 24 }; private readonly Button _moveAddressLineUpButton = new() { Text = "Up", Width = 48, Height = 24 }; private readonly Button _moveAddressLineDownButton = new() { Text = "Down", Width = 56, Height = 24 }; - private readonly TextBox _fontFamilyInput = new() { Width = 160 }; + private readonly Button _fontFamilyInput = new() { Width = 160, Height = 24, TextAlign = ContentAlignment.MiddleLeft }; private readonly NumericUpDown _fontSizeInput = new() { DecimalPlaces = 1, Minimum = 1, Maximum = 1000m, Width = 80 }; private readonly Button _colorButton = new() { Text = string.Empty, Width = 60, Height = 24, FlatStyle = FlatStyle.Popup }; private readonly NumericUpDown _zOrderInput = new() { DecimalPlaces = 0, Minimum = 0, Maximum = 100000m, Width = 80 }; @@ -196,7 +199,13 @@ public sealed class TemplateDesignerForm : Form private readonly TextElementPropertiesEditor _propertiesEditor = new(); private readonly TemplateLayoutDocument _document; private readonly TemplateCanvasControl _canvas; - private readonly TemplatePreviewControl _previewControl; + private readonly CheckBox _showDataToggle = new() + { + Text = "Show data", + Appearance = Appearance.Button, + AutoSize = true, + Enabled = false, + }; /// Guards against re-entrant control-event handling while this form is /// programmatically updating a control's value in response to another control's change @@ -214,7 +223,6 @@ public sealed class TemplateDesignerForm : Form _document = document; _dimensionsEditor = new CanvasDimensionsEditor(document.Canvas); _canvas = new TemplateCanvasControl(document) { Dock = DockStyle.Fill, Margin = new Padding(12) }; - _previewControl = new TemplatePreviewControl(document) { Dock = DockStyle.Fill, Margin = new Padding(12, 0, 12, 12) }; Text = "Template Designer"; // Sprint 4: the properties panel grew from 7 rows to 9 (added Angle and Collapse-if-blank @@ -266,11 +274,13 @@ public sealed class TemplateDesignerForm : Form RefreshPropertiesPanel(); RefreshSelectionLabel(); } + }; - // Sprint 7 AC: the preview must auto-refresh after any layout edit. TemplatePreviewControl - // re-resolves against the live, shared document on every repaint, so an Invalidate() is - // all that's needed here — no separate "rebuild" step to keep in sync. - _previewControl.Invalidate(); + _showDataToggle.CheckedChanged += (_, _) => + { + _canvas.ShowData = _showDataToggle.Checked; + _showDataToggle.Text = _showDataToggle.Checked ? "Edit layout" : "Show data"; + RefreshSelectionLabel(); }; // Post-Sprint-7-review fix (2026-10-19): auto-navigate on every change instead of @@ -286,7 +296,6 @@ public sealed class TemplateDesignerForm : Form NavigateToRecord((int)_recordNumberInput.Value); } }; - _previewControl.SetContext(Array.Empty(), null); } /// The current layout state, kept in sync with the dimensions controls and the @@ -411,7 +420,6 @@ public sealed class TemplateDesignerForm : Form RefreshDimensionControls(); _canvas.ClearSelection(); _canvas.Invalidate(); - _previewControl.Invalidate(); SetFileStatus( $"Opened '{dialog.FileName}' ({_document.Elements.Count} element(s), {_document.AddressControls.Count} address control(s)).", @@ -443,8 +451,40 @@ public sealed class TemplateDesignerForm : Form return panel; } + private string? _dragColumnName; + private Point _dragStart; + private Control BuildCsvPreviewGrid() { + // Drag a column header onto the canvas to place that field (or add it to an Address + // Control): remember the header pressed, start the drag once the mouse moves far enough. + _csvPreviewGrid.MouseDown += (_, e) => + { + var hit = _csvPreviewGrid.HitTest(e.X, e.Y); + _dragColumnName = e.Button == MouseButtons.Left && hit.Type == DataGridViewHitTestType.ColumnHeader + ? _csvPreviewGrid.Columns[hit.ColumnIndex].HeaderText + : null; + _dragStart = e.Location; + }; + _csvPreviewGrid.MouseMove += (_, e) => + { + if (_dragColumnName is null || e.Button != MouseButtons.Left) + { + return; + } + + var drag = SystemInformation.DragSize; + if (Math.Abs(e.X - _dragStart.X) < drag.Width && Math.Abs(e.Y - _dragStart.Y) < drag.Height) + { + return; + } + + var column = _dragColumnName; + _dragColumnName = null; + _csvPreviewGrid.DoDragDrop(new DataObject(TemplateCanvasControl.ColumnDragFormat, column), DragDropEffects.Copy); + }; + _csvPreviewGrid.MouseUp += (_, _) => _dragColumnName = null; + var container = new Panel { Dock = DockStyle.Fill, @@ -526,6 +566,7 @@ public sealed class TemplateDesignerForm : Form } NavigateToRecord(1); + _showDataToggle.Enabled = true; SetCsvStatus( $"Loaded '{path}' ({result.Headers.Count} column(s), {result.SampleRows.Count} sample row(s)).", @@ -591,82 +632,9 @@ public sealed class TemplateDesignerForm : Form _csvStatusLabel.ForeColor = isError ? Color.Firebrick : Color.DarkGreen; } - /// Sprint 7: the canvas row's right side now holds two side-by-side areas — the new - /// record-accurate preview panel and the existing properties panel — laid out via a - /// with two fixed-width columns rather than two independently - /// Dock-ed controls, so their relative placement is deterministic by construction (row/column - /// position) instead of depending on Controls-collection add order. This deliberately avoids - /// the exact class of dock-order bug the Sprint 6 retrospective's live-verification action - /// caught (a hidden properties panel from ambiguous Dock ordering). - private Control BuildRightSidePanel() - { - const int previewWidth = 340; - const int propertiesWidth = 240; - - var panel = new TableLayoutPanel - { - Dock = DockStyle.Right, - Width = previewWidth + propertiesWidth, - ColumnCount = 2, - RowCount = 1, - }; - panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, previewWidth)); - panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, propertiesWidth)); - panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - - panel.Controls.Add(BuildPreviewArea(), 0, 0); - panel.Controls.Add(BuildPropertiesPanel(), 1, 0); - - return panel; - } - - /// Sprint 7: the record-navigation toolbar (record number) stacked above the - /// read-only preview surface, via row/column position rather than Dock ordering (see - /// 's remarks). Post-Sprint-7-review fix (2026-10-19): the - /// number input alone now drives navigation on every change (see the constructor's - /// _recordNumberInput.ValueChanged wiring) — no separate "Go" button anymore. - private Control BuildPreviewArea() - { - var container = new TableLayoutPanel - { - Dock = DockStyle.Fill, - ColumnCount = 1, - RowCount = 3, - }; - container.RowStyles.Add(new RowStyle(SizeType.AutoSize)); - container.RowStyles.Add(new RowStyle(SizeType.AutoSize)); - container.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - - var toolbar = new FlowLayoutPanel - { - AutoSize = true, - Padding = new Padding(12, 12, 3, 3), - FlowDirection = FlowDirection.LeftToRight, - }; - toolbar.Controls.Add(new Label - { - Text = "Record #:", - AutoSize = true, - Anchor = AnchorStyles.Left, - Margin = new Padding(3, 6, 3, 3), - }); - toolbar.Controls.Add(_recordNumberInput); - _recordStatusLabel.Margin = new Padding(12, 6, 3, 3); - toolbar.Controls.Add(_recordStatusLabel); - - var header = new Label - { - Text = "Preview (resolved values, read-only):", - AutoSize = true, - Padding = new Padding(12, 0, 0, 4), - }; - - container.Controls.Add(toolbar, 0, 0); - container.Controls.Add(header, 0, 1); - container.Controls.Add(_previewControl, 0, 2); - - return container; - } + /// The canvas row's right side holds the properties panel; record navigation and the + /// "Show data" toggle live in the element toolbar (see ). + private Control BuildRightSidePanel() => BuildPropertiesPanel(); /// Sprint 7, "Jump to a specific record number": fetches the requested 1-based /// record via (the full-file streaming reader, not the @@ -680,8 +648,8 @@ public sealed class TemplateDesignerForm : Form { if (string.IsNullOrEmpty(_currentCsvPath)) { - _previewControl.SetContext(Array.Empty(), null); - SetRecordStatus("No CSV loaded. Load a CSV to preview a record.", isError: false); + _canvas.SetCsvPreviewContext(Array.Empty(), null); + SetRecordStatus("No CSV loaded. Load a CSV to view record data.", isError: false); return; } @@ -692,7 +660,7 @@ public sealed class TemplateDesignerForm : Form return; } - _previewControl.SetContext(_loadedCsvHeaders, result.Record); + _canvas.SetCsvPreviewContext(_loadedCsvHeaders, result.Record); SetRecordStatus($"Showing record {recordNumber}.", isError: false); } @@ -717,8 +685,12 @@ public sealed class TemplateDesignerForm : Form AddPropertyRow(1 * rowHeight, "X (pt):", _xInput, leftMargin, labelTop, inputTop); AddPropertyRow(2 * rowHeight, "Y (pt):", _yInput, leftMargin, labelTop, inputTop); AddPropertyRow(3 * rowHeight, "Address width (pt):", _addressWidthInput, leftMargin, labelTop, inputTop); + var spacingLabel = new Label { Text = "Line spacing (x):", AutoSize = true, Location = new Point(leftMargin + 108, (3 * rowHeight) + labelTop) }; + _addressLineSpacingInput.Location = new Point(leftMargin + 108, (3 * rowHeight) + inputTop); + _propertiesPanel.Controls.Add(spacingLabel); + _propertiesPanel.Controls.Add(_addressLineSpacingInput); AddAddressLineControls(4 * rowHeight, leftMargin, labelTop, inputTop); - AddPropertyRow(7 * rowHeight, "Font family:", _fontFamilyInput, leftMargin, labelTop, inputTop); + AddPropertyRow(7 * rowHeight, "Font (click to choose):", _fontFamilyInput, leftMargin, labelTop, inputTop); AddPropertyRow(8 * rowHeight, "Font size (pt):", _fontSizeInput, leftMargin, labelTop, inputTop); AddPropertyRow(9 * rowHeight, "Color:", _colorButton, leftMargin, labelTop, inputTop); // Sprint 3, Batch 4 ("Re-map an existing dynamic field"): only meaningful for the legacy @@ -760,7 +732,8 @@ public sealed class TemplateDesignerForm : Form _xInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedX((double)_xInput.Value); } }; _yInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedY((double)_yInput.Value); } }; _addressWidthInput.ValueChanged += (_, _) => { if (!_suppressEvents && _canvas.SelectedAddressControl is not null) { _canvas.SelectedAddressControl.Width = (double)_addressWidthInput.Value; _canvas.NotifyElementChanged(); } }; - _fontFamilyInput.TextChanged += (_, _) => { if (!_suppressEvents) { SetSelectedFontFamily(_fontFamilyInput.Text); } }; + _addressLineSpacingInput.ValueChanged += (_, _) => { if (!_suppressEvents && _canvas.SelectedAddressControl is not null) { _canvas.SelectedAddressControl.LineSpacingMultiplier = (double)_addressLineSpacingInput.Value; _canvas.NotifyElementChanged(); } }; + _fontFamilyInput.Click += (_, _) => OnFontButtonClick(); _fontSizeInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedFontSize((double)_fontSizeInput.Value); } }; _zOrderInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedZOrder((int)_zOrderInput.Value); } }; _angleInput.ValueChanged += (_, _) => { if (!_suppressEvents) { SetSelectedRotationAngle((double)_angleInput.Value); } }; @@ -985,6 +958,50 @@ public sealed class TemplateDesignerForm : Form _canvas.NotifyElementChanged(); } + /// Opens the standard Windows font picker for the selected element/line, seeded with + /// its current family and size, and applies both the chosen family and size. + private void OnFontButtonClick() + { + if (_propertiesEditor.Selected is null && _canvas.SelectedAddressLine is null) + { + return; + } + + var currentFamily = _fontFamilyInput.Text; + var currentSize = (float)_fontSizeInput.Value; + Font seed; + try + { + seed = new Font(string.IsNullOrWhiteSpace(currentFamily) ? "Arial" : currentFamily, currentSize, GraphicsUnit.Point); + } + catch (ArgumentException) + { + seed = new Font("Arial", currentSize, GraphicsUnit.Point); + } + + using (seed) + { + using var dialog = new FontDialog { Font = seed, FontMustExist = true, ShowEffects = false }; + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + SetSelectedFontFamily(dialog.Font.FontFamily.Name); + SetSelectedFontSize(dialog.Font.SizeInPoints); + _suppressEvents = true; + try + { + _fontFamilyInput.Text = dialog.Font.FontFamily.Name; + _fontSizeInput.Value = ClampToNumericRange(_fontSizeInput, (decimal)dialog.Font.SizeInPoints); + } + finally + { + _suppressEvents = false; + } + } + } + private void OnColorButtonClick() { if (_propertiesEditor.Selected is null && _canvas.SelectedAddressLine is null) @@ -1089,6 +1106,9 @@ public sealed class TemplateDesignerForm : Form _addressWidthInput.Value = ClampToNumericRange( _addressWidthInput, (decimal)(selectedAddressControl?.Width ?? AddressControlLayout.DefaultWidth)); _addressWidthInput.Enabled = selectedAddressControl is not null; + _addressLineSpacingInput.Value = ClampToNumericRange( + _addressLineSpacingInput, (decimal)(selectedAddressControl?.LineSpacingMultiplier ?? AddressControlLayout.DefaultLineSpacingMultiplier)); + _addressLineSpacingInput.Enabled = selectedAddressControl is not null; RefreshAddressLineList(selectedAddressControl); _fontFamilyInput.Text = selected?.FontFamily ?? selectedAddressLine?.FontFamily ?? string.Empty; @@ -1230,6 +1250,21 @@ public sealed class TemplateDesignerForm : Form _selectionLabel.Margin = new Padding(18, 6, 3, 3); panel.Controls.Add(_selectionLabel); + // "Show data": swaps the canvas from the {Column} token view to the resolved values for + // the record chosen here (replaces the old separate Preview panel). + _showDataToggle.Margin = new Padding(18, 3, 3, 3); + panel.Controls.Add(_showDataToggle); + panel.Controls.Add(new Label + { + Text = "Record #:", + AutoSize = true, + Anchor = AnchorStyles.Left, + Margin = new Padding(6, 6, 3, 3), + }); + panel.Controls.Add(_recordNumberInput); + _recordStatusLabel.Margin = new Padding(6, 6, 3, 3); + panel.Controls.Add(_recordStatusLabel); + // Sprint 10, "Align and distribute multiple elements": a new button group, enabled only // while 2+ items are multi-selected (RefreshAlignmentButtonsEnabled, called from every // place selection state already gets refreshed). Appended to this same toolbar rather than