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”.

is positioned at 0,0 with the caption of “Nickname”. The nameText should be positioned below the nameLabel.

should be positioned below the 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

or 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:

File -> Exit 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.

Create a FrameView Create a ListView Add ListView to the FrameView 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: