using System.Drawing; using System.Windows.Forms; using EnvelopeRenderer.Desktop.Core.Launch; using EnvelopeRenderer.Desktop.Views; namespace EnvelopeRenderer.Desktop; /// /// 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. /// 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()) { } /// Internal constructor allowing a launcher to be injected (used by tests/composition). 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 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(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; } }