From 335f4baae809584974d19dbd378936a436b00ecf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:28:24 +0000 Subject: [PATCH] Show a numbered menu off Windows instead of crashing [patch] The default Menu verb displays a DustInTheWind ScrollMenu, which reads Console.CursorVisible. .NET only supports that on Windows, so running ImageDescriber with no arguments, or an unknown verb, crashed with PlatformNotSupportedException on Linux and macOS. Off Windows, list the verbs as numbered choices read from standard input, and stop at end of input. Fixes #165 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QtRTdtw1SbV7ugjz5wPFgc --- ImageDescriber.Test/MenuTests.cs | 90 ++++++++++++++++++++++++++++++++ ImageDescriber/Verbs/Menu.cs | 82 ++++++++++++++++++++++++----- 2 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 ImageDescriber.Test/MenuTests.cs diff --git a/ImageDescriber.Test/MenuTests.cs b/ImageDescriber.Test/MenuTests.cs new file mode 100644 index 0000000..4d488d9 --- /dev/null +++ b/ImageDescriber.Test/MenuTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImageDescriber.Tests; + +using ktsu.ImageDescriber.Verbs; + +[TestClass] +public class MenuTests +{ + [TestMethod] + public void RunWithoutArgumentsOffersMenuOffWindows() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Windows uses the interactive ScrollMenu, which needs a real console."); + return; + } + + TextReader originalIn = Console.In; + TextWriter originalOut = Console.Out; + using StringReader input = new(string.Empty); + using StringWriter output = new(); + Console.SetIn(input); + Console.SetOut(output); + + try + { + Menu menu = new(); + menu.Run(); + } + finally + { + Console.SetIn(originalIn); + Console.SetOut(originalOut); + } + + string text = output.ToString(); + StringAssert.Contains(text, "Scan"); + StringAssert.Contains(text, "Exit"); + Assert.IsFalse(text.Contains("1. Menu", StringComparison.Ordinal), "The menu should not list itself."); + } + + [TestMethod] + public void PromptMenuRunsTheChosenItemUntilExit() + { + List ran = []; + (string, Action)[] items = + [ + ("First", () => ran.Add("First")), + ("Second", () => ran.Add("Second")), + ]; + using StringReader input = new("2\n1\n3\n"); + using StringWriter output = new(); + + Menu.RunPromptMenu(input, output, items); + + Assert.AreEqual("Second,First", string.Join(",", ran)); + StringAssert.Contains(output.ToString(), "1. First"); + StringAssert.Contains(output.ToString(), "2. Second"); + StringAssert.Contains(output.ToString(), "3. Exit"); + } + + [TestMethod] + public void PromptMenuRejectsInvalidChoices() + { + List ran = []; + (string, Action)[] items = [("Only", () => ran.Add("Only"))]; + using StringReader input = new("bogus\n0\n3\n-1\n\n2\n"); + using StringWriter output = new(); + + Menu.RunPromptMenu(input, output, items); + + Assert.AreEqual(0, ran.Count); + string[] errors = output.ToString().Split("Enter a number from 1 to 2."); + Assert.AreEqual(6, errors.Length, "Each of the five invalid lines should be rejected."); + } + + [TestMethod] + public void PromptMenuStopsAtEndOfInput() + { + List ran = []; + (string, Action)[] items = [("Only", () => ran.Add("Only"))]; + using StringReader input = new("1\n"); + using StringWriter output = new(); + + Menu.RunPromptMenu(input, output, items); + + Assert.AreEqual("Only", string.Join(",", ran)); + } +} diff --git a/ImageDescriber/Verbs/Menu.cs b/ImageDescriber/Verbs/Menu.cs index 7177007..6330705 100644 --- a/ImageDescriber/Verbs/Menu.cs +++ b/ImageDescriber/Verbs/Menu.cs @@ -2,7 +2,10 @@ namespace ktsu.ImageDescriber.Verbs; +using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; +using System.IO; using System.Linq; using System.Reflection; @@ -16,6 +19,59 @@ namespace ktsu.ImageDescriber.Verbs; internal sealed class Menu : BaseVerb { internal override void Run(Menu options) + { + Type[] verbs = [.. Program.Verbs.Where(verb => verb != GetType())]; + + // ScrollMenu reads Console.CursorVisible, which .NET only supports on Windows + if (OperatingSystem.IsWindows()) + { + RunScrollMenu(verbs); + } + else + { + RunPromptMenu(Console.In, Console.Out, [.. verbs.Select(verb => (GetMenuText(verb), (Action)(() => CreateVerb(verb).Execute())))]); + } + } + + internal static void RunPromptMenu(TextReader input, TextWriter output, IReadOnlyList<(string Text, Action Run)> items) + { + int exitChoice = items.Count + 1; + + while (true) + { + output.WriteLine(); + for (int i = 0; i < items.Count; i++) + { + output.WriteLine($"{i + 1}. {items[i].Text}"); + } + + output.WriteLine($"{exitChoice}. Exit"); + output.Write("Choose an option: "); + + string? line = input.ReadLine(); + if (line is null) + { + // End of input: nobody is left to choose, so stop rather than prompt forever + output.WriteLine(); + return; + } + + if (!int.TryParse(line.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int choice) || choice < 1 || choice > exitChoice) + { + output.WriteLine($"Enter a number from 1 to {exitChoice}."); + continue; + } + + if (choice == exitChoice) + { + return; + } + + items[choice - 1].Run(); + } + } + + private static void RunScrollMenu(Type[] verbs) { bool exitRequested = false; @@ -31,9 +87,7 @@ internal override void Run(Menu options) Control = scrollMenu, }; - LabelMenuItem[] menuItems = [.. Program.Verbs - .Where(verb => verb != GetType()) - .Select(CreateMenuItem)]; + LabelMenuItem[] menuItems = [.. verbs.Select(CreateMenuItem)]; scrollMenu.AddItems(menuItems); scrollMenu.AddItem(new LabelMenuItem() @@ -55,20 +109,24 @@ private sealed class ActionCommand(Action action) : ICommand public void Execute() => action(); } - private static LabelMenuItem CreateMenuItem(Type verbType) + private static BaseVerb CreateVerb(Type verbType) { BaseVerb? verb = Activator.CreateInstance(verbType) as BaseVerb; Debug.Assert(verb != null); + return verb; + } + private static string GetMenuText(Type verbType) + { string name = verbType.Name; string? helpText = verbType.GetCustomAttribute()?.HelpText; - string text = string.IsNullOrEmpty(helpText) ? name : $"{name} - {helpText}"; - - return new LabelMenuItem() - { - Text = text, - Command = verb, - IsEnabled = true, - }; + return string.IsNullOrEmpty(helpText) ? name : $"{name} - {helpText}"; } + + private static LabelMenuItem CreateMenuItem(Type verbType) => new() + { + Text = GetMenuText(verbType), + Command = CreateVerb(verbType), + IsEnabled = true, + }; }