From e2ce8321e51bc03733b178357cf4681b281ae1e8 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 23 Aug 2026 17:54:09 +1000 Subject: [PATCH] Draw an ampersand where the text has one The status line and the context menu render solution names, paths and applier messages, and WinForms reads an ampersand in any of those as a mnemonic. A solution named R&D produced "Accept all in R_D" - with D live as an accelerator, so a keystroke meant for something else ran an accept-all. The status Label turns mnemonics off. Menu items have no such switch, so the ampersand is doubled, which is how a literal one is written. --- .../AmpersandTests.cs | 34 +++++++++++++++++++ src/DiffEngineViewer.Windows/ViewerForm.cs | 7 +++- src/DiffEngineViewer.Windows/ViewerMenu.cs | 11 +++++- 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/DiffEngineViewer.Windows.Tests/AmpersandTests.cs diff --git a/src/DiffEngineViewer.Windows.Tests/AmpersandTests.cs b/src/DiffEngineViewer.Windows.Tests/AmpersandTests.cs new file mode 100644 index 00000000..4af421c9 --- /dev/null +++ b/src/DiffEngineViewer.Windows.Tests/AmpersandTests.cs @@ -0,0 +1,34 @@ +/// +/// An ampersand in a solution name, a path or an applier message. WinForms reads one as a mnemonic +/// wherever it renders text, so "R&D" draws as "R_D" - with D live as an accelerator, which is +/// worse than the missing character. +/// +[NotInParallel] +[TUnit.Core.Executors.STAThreadExecutor] +public class AmpersandTests +{ + [Test] + public async Task A_menu_label_keeps_its_ampersand() + { + using var strip = ViewerMenu.Build(new(0, ["Accept all in R&D"])); + + var item = strip.Items + .Cast() + .Single(); + // Doubled, which is how a literal one is written. What is drawn is one + await Assert.That(item.Text).IsEqualTo("Accept all in R&&D"); + } + + [Test] + public async Task The_status_line_does_not_read_one_as_a_mnemonic() + { + using var form = new ViewerForm("title", 800, 600); + + var status = form.Controls + .Find("status", true) + .OfType() + .Single(); + + await Assert.That(status.UseMnemonic).IsFalse(); + } +} diff --git a/src/DiffEngineViewer.Windows/ViewerForm.cs b/src/DiffEngineViewer.Windows/ViewerForm.cs index fe3a3456..cfa7c4ed 100644 --- a/src/DiffEngineViewer.Windows/ViewerForm.cs +++ b/src/DiffEngineViewer.Windows/ViewerForm.cs @@ -20,10 +20,15 @@ sealed class ViewerForm : Form readonly Label status = new() { + Name = "status", Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleRight, ForeColor = Palette.Dim, - AutoSize = false + AutoSize = false, + // The status line is built from paths, solution names and whatever the applier said, and a + // Label reads an ampersand in any of those as a mnemonic: "R&D" drew as "R_D" with D live + // as an accelerator. + UseMnemonic = false }; readonly List pool = []; diff --git a/src/DiffEngineViewer.Windows/ViewerMenu.cs b/src/DiffEngineViewer.Windows/ViewerMenu.cs index 50014992..94b145f0 100644 --- a/src/DiffEngineViewer.Windows/ViewerMenu.cs +++ b/src/DiffEngineViewer.Windows/ViewerMenu.cs @@ -41,12 +41,21 @@ public static void Fill(ContextMenuStrip strip, MenuOverlay menu, Action cl for (var index = 0; index < menu.Labels.Count; index++) { var captured = index; - var item = new ToolStripMenuItem(menu.Labels[index]); + var item = new ToolStripMenuItem(Escape(menu.Labels[index])); item.Click += (_, _) => clicked(captured); strip.Items.Add(item); } } + /// + /// Menu labels carry solution names and file names - "Accept all in R&D" - and a menu item + /// reads an ampersand as a mnemonic, so that one drew as "Accept all in R_D" with D live as an + /// accelerator. Doubling it is how a literal one is written; ToolStripItem has no + /// UseMnemonic to turn the reading off. + /// + static string Escape(string label) => + label.Replace("&", "&&"); + /// /// A strip on its own, for tests and for anything that wants one without a window. ///