Terminal/Console User Interface in .NET Core

Ali Bahraminezhad
ITNEXT
Published in
12 min readJan 15, 2020

--

We don’t always use the desktop environment to interact with an application. Sometimes(or maybe always)we ssh into computers/servers and execute our commands inside a terminal.

The terminal/console-based GUI is not like a rich desktop/mobile app with images, and complex components, it’s all about characters. When I was at university, for the system programming language course, we had to write an application like Norton Commander(NC) in x86 Assembly language.

Ali Commander (AC) — My NC, I wrote in x86 Assembly years ago

At that time, writing console-based applications for me was just about printing ASCII characters to the screen, but it’s more than the characters.

In this article I will cover:

  1. How do Terminal/Console-based apps work?
  2. Introducing GUI.CS, a simple UI toolkit for .NET, .NET Core and Mono.
  3. Implementing a project with Gui.cs

It’s not required to read the 1st part, you can jump into the 2nd part to get your hand dirty with some code!

How doTerminal/Console-based apps work?

There are two modes of terminal display, Framebuffer, and Text-mode:

Text-mode: is a terminal display mode in which content is represented on the display in terms of characters and not individual pixels. Literally, the screen will be considered as a uniform rectangular grid of character cells. In text-mode application can use characters to display their UI on the terminal. For eg IBM PC Code page 437 offers several characters, such as box-drawing characters (like edges, lines, etc) that many applications used them to draw their UI. For instance, here(or in Ali Commander) I used box-drawing characters to draw my UI.

╔══════════════════════════════════════════╗
╟──────────────────────────────────────────╢
║ Progress 25% ║
║ ▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ║
║ ║
║ ┌────┐ ┌────────┐ ║
║ │ OK │ │ Cancel │ ║
║ ╘════╛ ╘════════╛ ║
╚══════════════════════════════════════════╝

Framebuffer: A graphic hardware-independent abstraction layer to show graphics on a display. Not like text-mode, Framebuffer uses graphics, pixels. The framebuffer is used on the Linux terminal. Here is an example of a framebuffer mode:

Framebuffer by Cow Computing Blog

Writing a Console-based GUI is difficult because you need to remember many things, the position of objects, pixels, characters, knowing when to re-draw, and the worst part is layers. Imagine you need to open a prompt to request an input from the user, you need to overlay the prompt dialog on your app, it’s insane!

And not everything is absolute! You need to know the height and width of the terminal/console. A default (text-mode) MS-DOS display is 80 columns by 25 rows. This is easy, you can keep everything fixed, but nowadays terminal emulators and console sizes could vary and users customize them.

In addition to all the difficulties, to create a console-based app you need to compute everything in percentage to make it responsive!

We know some information about a terminal/console-based app, it’s time to write some code.

Gui.cs — Terminal UI toolkit for .NET

Gui.cs is a UI toolkit for creating awesome console-based applications with .NET. Miguel de Icaza is the author of this library. This is how he introduces its tool:

This is an updated version of gui.cs that I wrote for mono-curses in 2007.

The original gui.cs was a UI toolkit in a single file and tied to curses. This version tries to be console-agnostic and instead of having a container/widget model, only uses Views (which can contain subviews) and changes the rendering model to rely on damage regions instead of burdening each view with the details.

— Miguel de Icaza https://github.com/migueldeicaza/gui.cs

Features:

  • Rich set of components such as buttons, labels, text entry, text view, time field, radio buttons, checkboxes, dialogs, windows, menus, list views, frames, progress bars, scroll bars, scroll views and a hex editor/viewer.
  • Good API documentation
  • NuGet Package
  • Supports .NET, .NET Core and Mono.
  • Works on Windows, Linux, and MacOS.
  • Mouse and keyboard input

The toolkit library is well-designed and it’s easy to use, but before everything, we need to learn some information and concepts.

Architecture and design

Gui.cs is a text-based toolkit, but it provides Gui in a way similar to graphic toolkits. Every visible element on the screen is implemented as Views. Views are self-contained objects that take care of displaying themselves. They can receive keyboard and mouse input and participate in the focus mechanism. Every view can contain zero or more children's views called sub-views.

- Layouting

Gui.cs supports two different layouts system, absolute and computed. The absolute system is used when you want to manually control where the view is by fixed points(x, y). The computed layout system offers a few additional capabilities, like automatic centering, expanding of dimensions and a handful of other features.

- TopLevel Views

Toplevel views have no visible user interface elements and occupy portions of the screen.

- Threads

Gui.cs isn’t thread-safe, but it has a workaround to make your app thread-safe.

The best way of learning a new framework, tool or library(of course after reading the docs first) is to work with it.

A simple chat application can broadcast messages to everyone online. Users can see online users.

Login UI
  1. Create a new console project
dotnet new console -o RetroChat

2. Add Terminal.Gui package to the project

dotnet add package Terminal.Gui

3. With your favorite editor or IDE open the project

code .

4. Inside Prgram.cs replace the contents with:

Before anything else, you need to init the console app by calling Application.Init. To create the UI, we need to get a top-level object. This can be done by calling Application.Top property.

The RetroChat application requires a window, so we created a new Window object. The title is “RetroChat”, it is positioned to the 0,1 and with the help of Dim.Fill method, the width and height of it will fill the screen.

What does 0,1 mean? They are x and y axes. They start from Top-Left which is 0, 0.

X, y axes and start from the top-left

If you just run the app, you will see a blue empty screen. It’s time to create the first real UI. You can write all the code inside the Program.cs, but to make things clear I create different files for different views in my application. To design and implement the Login window I create a different class.

This is our UI sketch:

Login UI

It requires a window, two text-fields, two labels, and two buttons. Let’s create a class called LoginWindow and fill it with the contents below:

I created LoginWindow class and it has inherited from the Window class. So we need to call at least one of the constructors of the Window class.

base("Login", 5)// Title and Margin

Since the window requires two buttons, I defined two Action as events, OnExit and OnLogin. To know the parent of the window, I also defined a constructor parameter parent.

Then we need to tell where(position) and how(width and height) the window should appear. There are two classes Dim and Pos which are a great help for creating UI in Gui.cs.

Here I needed to open the login window, in the center screen, so I defined X = Pos.Center(). it calculates the center and sets the value, then I need to the window be the 50% of the screen, I can set it Width = Dim.Percent(50).

Gui.cs has a class for every control, so it’s easy to create different instances of them.

var nameLabel = new Label(0, 0, "Nickname");
var nameText = new TextField("")
{
X = Pos.Left(nameLabel),
Y = Pos.Top(nameLabel) + 1,
Width = Dim.Fill()
};
Add(nameLabel);
Add(nameText);
  • The nameLabel is positioned at 0,0 with the caption of “Nickname”.
  • The nameText should be positioned below the nameLabel.
  • We prefer the width of the text-box fills a row.
  • It’s important to add every View to the container, this can be done by the Add method. In our example, LoginWindow has inherited from the Window, and a window is also a view and that’s why we have access to the Add method.
var loginButton = new Button("Login", true) // Text, default button?
{
X = Pos.Left(birthText),
Y = Pos.Top(birthText) + 1
};
var exitButton = new Button("Exit")
{
X = Pos.Right(loginButton) + 5,
Y = Pos.Top(loginButton)
};
// add them to the container
Add(exitButton);
Add(loginButton);

Creating buttons is the same as other controls, The constructor requires two parameters: the text and a boolean value to determine if the button is selected by default. In our example, buttons should be next to each other, so they have the same top, but different height. Here I used Pos.Right to place the exit button next to the login button.

A button without action doesn’t make sense, we need to set an action when they are clicked. Every button has a property called OnClicked and can be set like below:

exitButton.Clicked = () =>
{
OnExit?.Invoke();
Close();
};

In the LoginWindow class. I defined two actions to act as events of the windows. so when a user clicks on exitButton it will invoke OnExit action.

Login has more logic than the exit button, such as validation.

loginButton.Clicked = () =>
{
if (nameText.Text.ToString().TrimStart().Length == 0)
{
MessageBox.ErrorQuery(25, 8, "Error", "Name cannot be empty.", "Ok");
return;
}
var isDateValid = DateTime.TryParse(birthText.Text.ToString(), out DateTime birthDate);if (string.IsNullOrEmpty(birthText.Text.ToString()) || !isDateValid)
{
MessageBox.ErrorQuery(25, 8, "Error", "Date is required\nor is invalid.", "Ok");
return;
}
OnLogin?.Invoke((name: nameText.Text.ToString(), birthday: birthDate));Close();
};

To access every text-field value, you can easily use a getter called Text. During the validation, in case of any error, we need to show a message to the user. Luckily, Gui.cs has a class for it. With MessageBox.Query or MessageBox.ErrorQuery you can show message dialogs. Here you can see how I show an error to the user.

I also defined a special method called Close for LoginWindow:

public void Close()
{
_parent?.Remove(this);
}

If you just open a window over another window, they will overlay each other. In RetroChat the login dialog only appears at the beginning of the app and after login, it should be closed. To hide it from the screen, I need to ask the parent view to remove it from the display.

Now let’s back to Program.cs to show the login window, just change the main method code to the below.

Application.Init();
var top = Application.Top;
var mainWindow = new Window("Retro Chat")
{
X = 0,
Y = 1, // Leave one row for the toplevel menu
// By using Dim.Fill(), it will automatically resize without manual intervention
Width = Dim.Fill(),
Height = Dim.Fill()
};
// login window will be appear on the center screen
var loginWindow = new LoginWindow(mainWindow);
mainWindow.Add(loginWindow);
Application.Run(mainWindow);

As you can see, it’s just the same as adding controls to a window, we just added our window to a window. You can see how dialog boxes and the window appears on the window.

Sample runtime

It’s time for the chat UI:

We start from the menu-bar, it should have 2 menus:

  1. File -> Exit
  2. Help -> About

Menus can be easily created by MenuBar, MenuBarItem, and MenuItem:

  • MenuBar is the main top menu.
  • MenuBarItem is first-level menu items such as File, Help.
  • MenuItem is sub-items such as Exit and About.
  • Every menu has started with an underline, that’s for the shortcut, for example by pressing Alt+F, user can access the file menu.

We added the menu-bar and login-window to the app. As you can see there is something annoying with the UI; we haven’t logged in yet, but the menu is visible to the user.

On Program class make these changes:

// login window will be appear on the center screen
var loginWindow = new LoginWindow(null);
loginWindow.OnExit = () => Application.RequestStop();
loginWindow.OnLogin = (loginData) =>
{
mainWindow.Add(menu);
Application.Run(top);
};
top.Add(mainWindow);// run login-window-first
Application.Run(loginWindow);

We added mainWindow to top-level view, we run the app by loginWindow first.

Inside login-window, change the close method to this:

public void Close()
{
Application.RequestStop();
_parent?.Remove(this);
}

We called Application.RequestStop , this method will request to terminate the most top object. In our example the most top object is login-window , and immediately after that, in the login method, we run the application with another view.

The result will be something like below:

By now, we learned how to add controls, how to position them, how to close a window and how to return to the previous view.

We added the menu-bar, let’s add the other controls.

We need a box for the chats area. We can use FrameView, it works like a GroupBox. We also need a list to show the messages.

  1. Create a FrameView
  2. Create a ListView
  3. Add ListView to the FrameView
  4. Add FrameView to the MainWindow
#region chat-view
var chatViewFrame = new FrameView("Chats")
{
X = 0,
Y = 1,
Width = Dim.Percent(75),
Height = Dim.Percent(80),
};
var chatView = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
};
chatViewFrame.Add(chatView);
mainWindow.Add(chatViewFrame);
#endregion

Online user-list is also the same as messages:

#region online-user-list
var userListFrame = new FrameView("Online Users")
{
X = Pos.Right(chatViewFrame),
Y = 1,
Width = Dim.Fill(),
Height = Dim.Fill()
};
var userList = new ListView(_users)
{
Width = Dim.Fill(),
Height = Dim.Fill()
};
userListFrame.Add(userList);
mainWindow.Add(userListFrame);
#endregion

Pos and Dim classes are helpful; for example, I only mentioned the userListFrame is positioned in the right of the chatViewFrame, and it should fill the rest of the screen. Dim and Pos computed all the width, heights and positions.

Chat-bar is a bit different; it includes a Button and a TextField:

#region chat-bar
var chatBar = new FrameView(null)
{
X = 0,
Y = Pos.Bottom(chatViewFrame),
Width = chatViewFrame.Width,
Height = Dim.Fill()
};
var chatMessage = new TextField("")
{
X = 0,
Y = 0,
Width = Dim.Percent(75),
Height = Dim.Fill()
};
var sendButton = new Button("Send", true)
{
X = Pos.Right(chatMessage),
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
sendButton.Clicked = () =>
{
Application.MainLoop.Invoke(() =>
{
_messages.Add($"{_username}: {chatMessage.Text}");
chatView.SetSource(_messages);
chatMessage.Text = "";
});
};
chatBar.Add(chatMessage);
chatBar.Add(sendButton);
mainWindow.Add(chatBar);
#endregion

The same concepts but check the sendButton.Clicked code:

sendButton.Clicked = () =>
{
Application.MainLoop.Invoke(() =>
{
_messages.Add($"{_username}: {chatMessage.Text}");
chatView.SetSource(_messages);
chatMessage.Text = "";
});
};

As I already mentioned, Terminal.GUI is not thread-safe by default. If you are going to change something in UI that is called from different threads, you need to wrap it inside an invoke method like above.

To make things a bit realistic, I create a Dummy class to run in another thread adding new users to the chat-room.

public static class DummyChat
{
public static Action<(string name, DateTime birthday)> OnUserAdded;
private static readonly object _mutex = new object();
private static Thread _main;
public static void StartSimulation()
{
lock (_mutex)
{
if (_main == null)
{
_main = new Thread(new ThreadStart(Simulate));
_main.Start();
}
}
}
private static void Simulate()
{
int counter = 0;
while (++counter <= 10)
{
var name = $"User {counter}";
OnUserAdded?.Invoke((name, DateTime.Now));
Thread.Sleep(2000);
}
}
}

Inside Program.cs, I will call StartSimulation to run a thread in the background:

var loginWindow = new LoginWindow(null)
{
OnExit = Application.RequestStop,
OnLogin = (loginData) =>
{
// for thread-safety
Application.MainLoop.Invoke(() =>
{
_users.Add(loginData.name);
_username = loginData.name;
userList.SetSource(_users);
});
DummyChat.StartSimulation();
Application.Run(top);
}
};
top.Add(mainWindow);DummyChat.OnUserAdded = (loginData) =>
{
Application.MainLoop.Invoke(() =>
{
_users.Add(loginData.name);
userList.SetSource(_users);
});
};

After login, the simulation will start, and every 2 seconds, and it’s going to add a new user to the app. After these changes, the program.cs should look like this:

I tried to cover fundamentals such as creating controls, creating windows, nested layouts and multi-threading. Terminal.Gui is an awesome toolkit and it has more features.

You can download, fork, clone the sample project on my repository.

--

--

I’m a geek, a software engineer who likes to build amazing stuff 😉Here on Medium I write about different things but mostly focused on programming and .NET!