How often do you get to write a simple console util that should be used by humans?
When I need to write one I always question the best input methods for choosing different options. one way is to add command line arguments, but this will require it to run from the command line, another option is to add some ReadLine()
calls to get a string to a character that will match an option, while this is a great options I wanted a more familiar UX. All this led me to write the Console UI Menu.
I wanted the user to use the arrow keys to navigate the menu and i want it to be reusable in a declarative manner, so here we go.
You can find the entire code at this github repository
https://github.com/arthurvaverko/ConsoleMenu
It is also available using the beloved nuget package manager here:
https://www.nuget.org/packages/ConsoleUI.ConsoleMenu/
So we will start with out console item.
public class ConsoleMenuItem<T>
{
public string Name { get; set; }
public Action<T> CallBack { get; set; }
public T UnderlyingObject { get; set; }
public override int GetHashCode()
{
return Name.GetHashCode() ^ UnderlyingObject.GetHashCode();
}
public override bool Equals(object obj)
{
// Check for null values and compare run - time types.
if (obj == null || GetType() != obj.GetType())
return false;
var item = (ConsoleMenuItem<T>)obj;
return item.GetHashCode() == this.GetHashCode();
}
public override string ToString()
{
return $"{Name} (data: {UnderlyingObject.ToString()})";
}
public ConsoleMenuItem(string label, Action<T> callback, T underlyingObject)
{
Name = label;
CallBack = callback;
UnderlyingObject = underlyingObject;
}
}
Lets inspect the properties.
We have Name
which is the visual console representation of this item.
Then CallBack
is a delegate that will run once the item is selected, And finally we have UnderlyingObject
of type T
which is the data that we will send to the invoked callback.
Now lets take a look at the menu itself
public class ConsoleMenu<T>
{
public ConsoleMenuItem<T>[] _MenuItems { get; set; }
private string _Description;
private int _SelectedItemIndex = 0;
private bool _ItemIsSelcted = false;
public ConsoleMenu(string description, IEnumerable<ConsoleMenuItem<T>> menuItems)
{
_MenuItems = menuItems.ToArray();
_Description = description;
}
public void RunConsoleMenu()
{
if (!string.IsNullOrEmpty(_Description))
{
Console.WriteLine($"{_Description}: {Environment.NewLine}");
}
StartConsoleDrawindLoopUntilInputIsMade();
// reset the selection flag so we can re-draw the same console if required after selection
_ItemIsSelcted = false;
_MenuItems[_SelectedItemIndex].CallBack.Invoke(_MenuItems[_SelectedItemIndex].UnderlyingObject);
}
// ... more code later...
}
So here we define the menu as an array of ConsoleMenuItem
Menu Items, I used an array so that we can easily get the item by its index.
After the usual and utterly boring initialization in the constructor we get out holy grail: StartConsoleDrawindLoopUntilInputIsMade()
This method will run a loop that will
- Write all menu items of the console on screen
- Wait for a user key press
- if and arrow was pressed it wil re-write the console but with the next \ previous item selected
- if Enter was pressed it will set the
_SelectedItemIndex
and return to the calling code - The calling method will use the
_SelectedItemIndex
to find the item in the_MenuItems
array and invoke the delegate with its underlying object.
So lets demystify the process of StartConsoleDrawindLoopUntilInputIsMade ()
private void StartConsoleDrawindLoopUntilInputIsMade()
{
int topOffset = Console.CursorTop;
int bottomOffset = 0;
ConsoleKeyInfo kb;
Console.CursorVisible = false;
while (!_ItemIsSelcted)
{
for (int i = 0; i < _MenuItems.Length; i++)
{
WriteConsoleItem(i, _SelectedItemIndex);
}
bottomOffset = Console.CursorTop;
kb = Console.ReadKey(true);
HandleKeyPress(kb.Key);
//Reset the cursor to the top of the screen
Console.SetCursorPosition(0, topOffset);
}
//set the cursor just after the menu so that the program can continue after the menu
Console.SetCursorPosition(0, bottomOffset);
Console.CursorVisible = true;
}
We run in a constant loop that check if a selection was made using while (!_ItemIsSelcted)
every iteration will draw the console items using WriteConsoleItem(i, _SelectedItemIndex)
that will identify who is the currently marked item and draw it appropriately.
private void WriteConsoleItem(int itemIndex, int selectedItemIndex)
{
if (itemIndex == selectedItemIndex)
{
Console.BackgroundColor = ConsoleColor.Gray;
Console.ForegroundColor = ConsoleColor.Black;
}
Console.WriteLine(" {0,-20}", this._MenuItems[itemIndex].Name);
Console.ResetColor();
}
Then do some tricks with the console cursor so that we wont see it blinking and if we write something it will not write over the console and finally will wait for the user’s key press by calling HandleKeyPress(kb.Key);
private void HandleKeyPress(ConsoleKey pressedKey)
{
switch (pressedKey)
{
case ConsoleKey.UpArrow:
_SelectedItemIndex = (_SelectedItemIndex == 0) ? _MenuItems.Length - 1 : _SelectedItemIndex - 1;
break;
case ConsoleKey.DownArrow:
_SelectedItemIndex = (_SelectedItemIndex == _MenuItems.Length - 1) ? 0 : _SelectedItemIndex + 1;
break;
case ConsoleKey.Enter:
_ItemIsSelcted = true;
break;
}
}
The HandleKeyPress
method will handle 3 types of keys. Arrow up, Arrow down and Enter. for navigation with the arrows we need to verify that we are not out of bounds and just not allow incrementing the selected index if we are about to cross the bound. and if Enter was pressed then we set the _ItemSelected
which will break the loop and invoke the delegate attached to the item in the selected index.
Here is a usage example for a directory browser using console menu:
using ConsoleUI;
using System;
using System.IO;
using System.Linq;
namespace ConsoleMenuTest
{
class Program
{
static void Main(string[] args)
{
OpenDirectoryBrowserConsole(@"C:\");
}
private static void OpenDirectoryBrowserConsole(string rootDirPath)
{
var dirs = Directory.GetDirectories(rootDirPath).Select(dPath =>
{
var dirName = Path.GetFileName(dPath);
var dirInfo = new DirectoryInfo(dPath);
return new ConsoleMenuItem<DirectoryInfo>(dirName, DirectoryCallback, dirInfo);
}).ToList();
dirs.Insert(0, new ConsoleMenuItem<DirectoryInfo>("..", DirectoryCallback, new DirectoryInfo(rootDirPath).Parent));
var menu = new ConsoleMenu<DirectoryInfo>($"DIR: {Path.GetFileName(rootDirPath)}", dirs);
menu.RunConsoleMenu();
}
private static void DirectoryCallback(DirectoryInfo selectedDirInfo)
{
Console.Clear();
OpenDirectoryBrowserConsole(selectedDirInfo.FullName);
}
private static void ItemCallback(string itemClicked)
{
Console.Clear();
Console.WriteLine(itemClicked);
}
}
}
I hat lots of fun writing this menu. I already used it several times to provide a user friendly console app.
Keep in mind that there is an issue with a menu that include items that pass the console height, the first items will not display. Was thinking about paging as a solution but its a very rare issue for me.. usually its 3 – 4 options, anyway if you feel like hacking into it feel free to pull me on github or let me know what you think.