Console UI Menu

Illustration of how Console Menu works

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

  1. Write all menu items of the console on screen
  2. Wait for a user key press
  3. if and arrow was pressed it wil re-write the console but with the next \ previous item selected
  4. if Enter was pressed it will set the _SelectedItemIndex and return to the calling code
  5. 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.

Advertisement

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.