|
- using System.Drawing;
- using System.Windows.Forms;
- using EnvelopeRenderer.Desktop.Core.Launch;
- using EnvelopeRenderer.Desktop.Views;
-
- namespace EnvelopeRenderer.Desktop;
-
- /// <summary>
- /// Operator-facing shell: pick a template, CSV, and output PDF path, then launch a render and
- /// watch it through to completion. Sprint 1, Batch 4 ("Launch a text-only render from the
- /// desktop app") proved the process could be launched without freezing the UI; Batch 5 ("Show
- /// render progress and completion summary") adds reading the CLI's `PROGRESS` stream while it
- /// runs, showing a non-technical completion summary once it exits, and — since the button is now
- /// only re-enabled after the process actually exits rather than as soon as it starts — closes the
- /// double-launch gap previously logged in logs/technical_debt_log.md.
- ///
- /// All non-UI logic (input validation, argument construction, CLI discovery, process launch,
- /// progress parsing, and summary formatting) lives in EnvelopeRenderer.Desktop.Core.Launch and is
- /// unit tested there — this class is intentionally thin event-handler wiring over that logic.
- /// </summary>
- public sealed class MainForm : Form
- {
- private readonly TextBox _templatePathTextBox = new();
- private readonly TextBox _csvPathTextBox = new();
- private readonly TextBox _outputPathTextBox = new();
- private readonly Button _renderButton = new() { Text = "&Render", AutoSize = true };
- private readonly Button _designTemplateButton = new() { Text = "&Design Template...", AutoSize = true };
- private readonly Label _statusLabel = new()
- {
- AutoSize = false,
- Height = 48,
- TextAlign = ContentAlignment.TopLeft,
- };
-
- private readonly CliProcessLauncher _launcher;
-
- public MainForm() : this(new CliProcessLauncher())
- {
- }
-
- /// <summary>Internal constructor allowing a launcher to be injected (used by tests/composition).</summary>
- internal MainForm(CliProcessLauncher launcher)
- {
- _launcher = launcher;
-
- Text = "Envelope Renderer";
- MinimumSize = new Size(640, 260);
- StartPosition = FormStartPosition.CenterScreen;
-
- Controls.Add(BuildLayout());
- }
-
- private Control BuildLayout()
- {
- var layout = new TableLayoutPanel
- {
- Dock = DockStyle.Fill,
- ColumnCount = 3,
- RowCount = 5,
- Padding = new Padding(12),
- AutoSize = true,
- };
- layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
- layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
- layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
-
- AddPickerRow(
- layout,
- row: 0,
- labelText: "Template (XML):",
- textBox: _templatePathTextBox,
- browse: () => BrowseForOpenFile(_templatePathTextBox, "XML template files (*.xml)|*.xml|All files (*.*)|*.*"));
-
- AddPickerRow(
- layout,
- row: 1,
- labelText: "CSV data:",
- textBox: _csvPathTextBox,
- browse: () => BrowseForOpenFile(_csvPathTextBox, "CSV files (*.csv)|*.csv|All files (*.*)|*.*"));
-
- AddPickerRow(
- layout,
- row: 2,
- labelText: "Output PDF:",
- textBox: _outputPathTextBox,
- browse: () => BrowseForSaveFile(_outputPathTextBox, "PDF files (*.pdf)|*.pdf|All files (*.*)|*.*"));
-
- _renderButton.Click += OnRenderClick;
- _designTemplateButton.Click += (_, _) => OnDesignTemplateClick();
- var buttonPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, AutoSize = true };
- buttonPanel.Controls.Add(_renderButton);
- buttonPanel.Controls.Add(_designTemplateButton);
- layout.Controls.Add(buttonPanel, 1, 3);
- layout.SetColumnSpan(buttonPanel, 2);
-
- _statusLabel.Dock = DockStyle.Fill;
- layout.Controls.Add(_statusLabel, 0, 4);
- layout.SetColumnSpan(_statusLabel, 3);
-
- return layout;
- }
-
- private static void AddPickerRow(TableLayoutPanel layout, int row, string labelText, TextBox textBox, Action browse)
- {
- var label = new Label
- {
- Text = labelText,
- AutoSize = true,
- Anchor = AnchorStyles.Left,
- Margin = new Padding(3, 9, 3, 3),
- };
-
- textBox.Dock = DockStyle.Fill;
- textBox.Margin = new Padding(3, 6, 3, 3);
-
- var browseButton = new Button { Text = "Browse...", AutoSize = true, Margin = new Padding(3, 3, 3, 3) };
- browseButton.Click += (_, _) => browse();
-
- layout.Controls.Add(label, 0, row);
- layout.Controls.Add(textBox, 1, row);
- layout.Controls.Add(browseButton, 2, row);
- }
-
- private static void BrowseForOpenFile(TextBox target, string filter)
- {
- using var dialog = new OpenFileDialog { Filter = filter, CheckFileExists = true, Title = "Select a file" };
- if (dialog.ShowDialog() == DialogResult.OK)
- {
- target.Text = dialog.FileName;
- }
- }
-
- private static void BrowseForSaveFile(TextBox target, string filter)
- {
- using var dialog = new SaveFileDialog { Filter = filter, OverwritePrompt = false, Title = "Choose where to save the PDF" };
- if (dialog.ShowDialog() == DialogResult.OK)
- {
- target.Text = dialog.FileName;
- }
- }
-
- private async void OnRenderClick(object? sender, EventArgs e)
- {
- var inputs = new RenderLaunchInputs(_templatePathTextBox.Text, _csvPathTextBox.Text, _outputPathTextBox.Text);
-
- var validation = RenderLaunchValidator.Validate(inputs);
- if (!validation.IsValid)
- {
- SetStatus(string.Join(" ", validation.Errors), isError: true);
- return;
- }
-
- var cliPath = CliExecutablePathResolver.Resolve(AppContext.BaseDirectory);
- if (!cliPath.IsFound)
- {
- SetStatus(cliPath.Error!, isError: true);
- ShowLaunchFailure(cliPath.Error!);
- return;
- }
-
- // Disabled here and only re-enabled once RunAsync's awaited Task completes below (i.e.
- // once the child process has actually exited) — not the moment it starts — so an
- // operator can't launch a second render against the same output while one is in flight.
- _renderButton.Enabled = false;
- SetStatus("Starting render...", isError: false);
-
- try
- {
- var arguments = CliArgumentListBuilder.Build(inputs);
-
- // Constructed on the UI thread so Progress<T> captures this thread's
- // SynchronizationContext and marshals each Report() call back to it automatically —
- // OnRenderProgress can update controls directly without a manual Invoke.
- var progress = new Progress<ProgressEvent>(OnRenderProgress);
-
- var result = await _launcher.RunAsync(cliPath.Path!, arguments, progress);
-
- var summary = RenderCompletionSummaryFormatter.Format(result);
- if (result.Succeeded)
- {
- SetStatus(summary, isError: false);
- }
- else if (!result.Started)
- {
- // A launch failure (the process never started) keeps the message-box treatment
- // from Batch 4 — it means something is wrong with the desktop app's own setup,
- // not with the operator's data, and deserves an explicit acknowledgement.
- SetStatus(summary, isError: true);
- ShowLaunchFailure(summary);
- }
- else
- {
- SetStatus(summary, isError: true);
- }
- }
- finally
- {
- _renderButton.Enabled = true;
- }
- }
-
- private void OnDesignTemplateClick()
- {
- using var designer = new TemplateDesignerForm();
- designer.ShowDialog(this);
- }
-
- private void OnRenderProgress(ProgressEvent progressEvent)
- {
- SetStatus(RenderProgressStatusFormatter.Format(progressEvent), isError: progressEvent.Kind == ProgressEventKind.Failure);
- }
-
- private void ShowLaunchFailure(string message)
- {
- MessageBox.Show(this, message, "Could not start render", MessageBoxButtons.OK, MessageBoxIcon.Error);
- }
-
- private void SetStatus(string message, bool isError)
- {
- _statusLabel.Text = message;
- _statusLabel.ForeColor = isError ? Color.Firebrick : Color.DarkGreen;
- }
- }
|