Loading…

The Peacenet: Writing an Efficient, User-Friendly and Feature-Rich Terminal Emulator

Holy crap that was a lot of technobabble in the title but I assure you it makes sense. In this article I’m going to go into full detail about how we implemented a fast, feature-packed and easy-to-use terminal emulator in the MonoGame rewrite of The Peacenet, our open-source stealth hacking game.

Let’s talk word wrapping for a bit.

Word-wrapped text in our terminal

The biggest part of the new terminal is, surprisingly enough, word-wrapping! Old Peacenet terminals actually use letter-wrapping for…reasons, but we figured that – since we’re writing our text layout code by hand – we might as well take full advantage of our own code in the Terminal to hopefully make command output a bit more readable when it goes beyond the width of the terminal.

Yes, we are aware that most Terminals don’t word-wrap on their own – that’s usually handled by whatever shell or program is outputting text – but we’re also aware that this is a game and, quite frankly, I care more about the game LOOKING like a Linux OS than necessarily ACTING like one. So I’m going to take some liberties.

The algorithm

I’m not just going to throw that image in your face without explaining how we pulled it off this well. Actually, I’m going to throw some code in your face and explain what it’s doing, how it’s doing what it’s doing, and why it’s doing what it’s doing how it’s doing it. Wow that sentence just fried about 50% of the readers of this article. Including myself!

public static string WordWrap(SpriteFont font, string text, float width)
{
    // Dear reader,
    //
    // If you've ever played Hacknet and been curious about how Matt did his
    // word-wrapping - instead of just enjoying the gameplay, you're weird.  So am
    // I.  I asked Matt how he did it and he explained the algorithm he settled
    // on.  This is that algorithm, and I'll level with you.  It's a lot better than
    // any word-wrapping code I've written on my own.
    //
    // Matt didn't give me any code, just an idea of what to do, so this is my implementation
    // of the Hacknet word wrapping code.
    //
    // - Michael, or as my English teacher calls me, "Lyfox."

    // Resulting wrapped string...
    StringBuilder sb = new StringBuilder();

    // Current line width.
    float lineWidth = 0;

    // Copy of the source string.
    string srcCpy = text;

    // Keep going until the source string is empty.
    while(!string.IsNullOrEmpty(srcCpy))
    {
        // Current word...
        string word = "";

        // Scan from the beginning of the src string until we
        // hit a space or newline.
        for(int i = 0; i < srcCpy.Length; i++)
        {
            char c = srcCpy[i];

            // Append the char to the string.
            word += c;

            // If the char is a space or newline, end.
            if (c == '\n' || c == ' ') break;
        }

        // Measure the word to get the width.
        float wordWidth = font.MeasurePrintable(word).X;

        // If the word can't fit on the current line then this is where we wrap.
        if(lineWidth + wordWidth > width && lineWidth > 0)
        {
            sb.Append(Environment.NewLine);
            lineWidth = 0;
        }

        // Now, while the word CAN'T fit on its own line, then we'll find a substring that can.
        while(wordWidth > width)
        {
            string substr = "";
            for(int i = 0; i < word.Length; i++)
            {
                char sc = word[i];
                if (font.MeasurePrintable(substr + sc).X > width)
                    break;
                substr += sc;
            }

            // Append the substring and then remove it from the word.
            sb.Append(substr.TrimEnd());
            word = word.Remove(0, substr.Length);
            srcCpy = srcCpy.Remove(0, substr.Length);
            wordWidth -= font.MeasurePrintable(substr).X;
            sb.Append(Environment.NewLine);
        }

        // Append the word and increment the line width.
        sb.Append(word);
        lineWidth += wordWidth;

        // If the word ends with a newline then line width is zero.
        if (word.EndsWith("\n"))
            lineWidth = 0;

        // Remove the word from the source string.
        srcCpy = srcCpy.Remove(0, word.Length);
    }

    return sb.ToString();
}

First I’ll mention something important: As noted in the big comment at the top, the algorithm itself is actually from Hacknet, developed by Matt Trobbiani. He didn’t give me any code – so that actually is my own code up there – but he did give me a general idea of how Hacknet handles word wrapping and it’s that algorithm that my code is implementing. So this is my implementation of, essentially, some fundamental Hacknet UI layout code.

What’s the code doing?

The code above takes a SpriteFont, a string of text, and a maximum line width. Given these values, it measures the printable width of each word in the string and determines if each word can fit on the current line. At the end of it all, it returns a new string that’s been modified so that not a single line of text in the string exceeds the maximum printable width.

How’s the code doing it?

Are you guys and gals ready for a fistful of explaining? I hope so. I’m not great at explaining code but… here goes.

  1. We start by seeking through each character in the original string, appending it to a new string called word, until we hit a space or newline (\n) character.
  2. We measure the physical width of word using SpriteFont.MeasurePrintable(), a method provided by Peace Engine. More on this later.
  3. If the word’s width plus the current line’s width is greater than the maximum line width, and the current line’s width is more than 0 (the current line isn’t empty), we append a newline to the wrapped text and set the current line width to 0.
  4. If the word width is greater than the maximum line width, and thus can’t fit on a line AT ALL, then we try to fit as much of the word on the line as we can, dropping to a new line when we run out of room, then we remove that section of the word from the word itself and the source string. We keep doing this until the remaining word can fit on a line.
  5. We then append the word (or remaining word if step 4 happened at all) to the wrapped string and add the word’s width to the current line’s width.
  6. If the word ends with a newline character then we set the current line width to 0. We don’t add a newline like we did in steps 3 and 4 because the newline is already in the word and has been added already in step 5.
  7. Now we remove the word from the source string!
  8. Run through steps 1 to 7 in a loop until the source string is completely empty. The text has now been word-wrapped.

Why is it doing it this way?

You may be wondering why we don’t just split the string into spaces using string.Split(' '). Technically that’ll work just fine, however it takes a TINY bit more RAM and precious CPU cycles. Think of it – a string is really just an array of characters, and the split method is going to give you an array of arrays of characters. The seeking method doesn’t require any of that.

Another interesting thing about the algorithm is the way it handles words that are too large to fit on their own line.

Word-wrapping algorithm handling large words that can’t fit on their own line. White and red rectangles indicate the terminal’s bounding box.

You can see each step of the algorithm at play here – The equal signs can’t fit on the same line as the first “test,” so Step 3 drops us to a new line. Since the equals signs can’t fit on their own line, Step 4 kicks in. Then, near the end, we have enough space for another “test” thanks to Step 4.

The scary thing is that the first Google search for “monogame word wrapping” leads to this StackOverflow thread that actually suggests recursing into the WrapText function to wrap large words like that. This is a very bad idea as it’ll most likely lead to possible bugs and stack overflow exceptions which are just great. Our method doesn’t use recursion at all, and you’ll notice it’s actually letter-wrapping. It HAS to letter-wrap, there’s no good way to split that word into multiple words.

MeasurePrintable

I promised I’d explain this later. So what’s the point of this? Well, we ran into an interesting logic issue with the Terminal. We needed a way to store color and formatting codes in the text as well as an easy indicator of the cursor position during rendering. We also realized that color and formatting codes may be useful outside of the Terminal. But if we word-wrap the text, and we try to measure a non-printable character/string, MonoGame will yell at us:

System.ArgumentException: Text contains characters that cannot be resolved by this SpriteFont. (Parameter: ‘text’)

And rightfully so! It can’t measure a character that it doesn’t know exists in the font! MeasurePrintable() is designed to parse out unprintable control characters before presenting you with the width of the printable string. The word wrapper will thus be able to preserve the unprintable characters and you won’t lose them.

Line Editing

The next feature of the Terminal in this build is true line editing. In Unreal, we had to hack in line editing and it was something that only command shells could do. Now it’s built into the terminal. This is another liberty I took when it comes to the terminal ACTING like a terminal, but rest assured it’ll look and feel just like a normal Peacenet terminal – except it’ll actually be way less buggy. Here’s what you can currently do:

  • Home: moves cursor to beginning of the line
  • End: Moves cursor to end of line
  • Left: Moves cursor left one character
  • Right: Moves cursor right one character
  • DEL: Deletes the character at the current cursor position
  • Backspace: Deletes the previous character, moving the cursor left by one character.
  • Enter: Submits the line to the game’s terminal input stream so commands can read the text you entered.

Terminal I/O API

I tried to make the terminal’s API as close to C#’s native Console class as much as possible. I’m not done yet, but I’ve come pretty close. One thing I’ve kept from the Unreal terminal is our PTY implementation, essentially meaning we in fact have a true STDOUT and STDIN stream for the terminal. These can be wrapped by our own Console object (I haven’t got that far yet) or used directly like this:

using BitPhoenix.Peacenet.Core;
using BitPhoenix.Peacenet.Core.Gui;
using BitPhoenix.Peacenet.Game.Controls;
using Microsoft.Xna.Framework.Content;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace BitPhoenix.Peacenet.Game.Screens
{
    public sealed class UnixTty : GuiScreen
    {
        private TaskService _taskService = null;
        private TerminalEmulator _terminal = null;

        protected override void OnLoadContent(ContentManager content)
        {
            _taskService = GetService<TaskService>();

            _terminal = new TerminalEmulator();
            _terminal.HorizontalAlignment = HorizontalAlignment.Center;
            _terminal.VerticalAlignment = VerticalAlignment.Center;
            Initialize(_terminal);

            _taskService.Monitor(Shell);
            base.OnLoadContent(content);
        }

        private async Task Shell()
        {
            var stdout = _terminal.StandardOutput;
            var stdin = _terminal.StandardInput;

            stdout.WriteLine("\x1B[36;1mTERMINAL TEST SCREEN\x1B[0m\r\n");
            stdout.WriteLine("This screen demos the Peacenet Terminal and the input/output streams associated with it.  We have spun up a simple echoing shell that will echo whatever you say to demo how easy the terminal is to use.");
            stdout.WriteLine("\r\nTry typing something in.  To return to the main menu type 'exit'.");

            string text = "";
            do
            {
                stdout.Write(">>> ");
                text = stdin.ReadLine();
                stdout.WriteLine(text);

            } while (text.ToLower() != "exit");

            // This code runs on the game thread.
            await _taskService.RunOnGameThread(GoHome);
        }

        private void GoHome()
        {
            ScreenManager.CreateScreen<MainMenu>();
            Close();
        }
    }
}

The important part is the Shell() method. We’re running this method asynchronously as stream operations block the current thread and we don’t want to block the game thread with this shell. Rest assured I’ve made the terminal streams thread-safe, so this is fine.

As you can see it’s just like writing to or reading from any System.IO.Stream object – that’s what these are! You can send ANSI terminal modes as output to control the text color and formatting.

Conclusion

There are many more features in the new terminal but they’re mostly things you don’t need to worry about. The next part of the Peace Engine to develop is our UI layout system, and then the themeing system – then I’ll show you guys how I’ve decided to lay out the new Peacegate OS. Until then, happy Thanksgiving for you other Canadians out there and happy weekend for the rest of you. 🙂

Leave a Reply