Emulating the Command Prompt in C# (Part 2)

Published August 07, 2011
Advertisement
Mirrored from my blog.

Last post I demonstrated how to make a simple command prompt using a basic Console project. This time I'm going to show how to embed one in a window.

I'll admit it, getting this to work was not particularly easy. I tried a number of different methods, from redirecting the output stream to parenting the console window. Redirecting the output stream didn't work because not all of the output could be read until the program finished executing. Parenting the console window didn't work because it wouldn't draw correctly when I removed the border. In the end, I had to copy the text from the console buffer itself.

Hooray, more P/Invoke.

Reading from the Console Window

There are few functions defined for manipulating console windows. Luckily, none of them have been wrapped. The most important ones are GetStdHandle and ReadConsoleOutputCharacter. The P/Invoke definitions are as such:
[source lang=csharp]
[StructLayout(LayoutKind.Sequential)]
private struct COORD
{
public short X;
public short Y;

public COORD(short X, short Y)
{
this.X = X;
this.Y = Y;
}
};

[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GetStdHandle(int nStdHandle);

[DllImport("kernel32.dll")]
private static extern bool ReadConsoleOutputCharacter(IntPtr hConsoleOutput, [Out] char[] lpCharacter, uint nLength, COORD dwReadCoord, out uint lpNumberOfCharsRead);
[/source]
GetStdHandle is used to get the handle to the standard output stream. That is then passed as the first argument to ReadConsoleOutputCharacter, which is the actual function for reading from the console output.

The annoying thing about reading from the console is that there are no line breaks. If you use ReadConsoleOutputCharacter to read the whole console at once, it gives you one huge line of text. Also, the console buffer is a fixed size - it doesn't expand when you write more text, it overwrites whatever's at the cursor. Ever wonder why, when you open the command prompt, you can scroll so far down even though there's nothing written? Those are all spaces.

The best solution I could come up with was to read the console one line at a time, adding the breaks and trimming the lines as you go.
[source lang=csharp]
var text = new StringBuilder();

for (int i = 0; i < Console.BufferHeight; i++)
text.AppendLine(GetLine(i));

var output = text.ToString().TrimEnd();

// elsewhere

private string GetLine(int line)
{
uint garbage;
if (!ReadConsoleOutputCharacter(handle, buffer, (uint)buffer.Length, new COORD(0, (short)line), out garbage))
throw new InvalidOperationException("Could not read console output.");

return new string(buffer).TrimEnd();
}
[/source]
That's all that's necessary to read directly from the console window. Just one little gotcha - there's no way (that I know of) to tell if the console has been updated with more text. It has to be constantly read and reread. Also, the reading process can be quite slow. My solution was to do the reading in a separate thread. Here is the whole ConsoleReader class.
[source lang=csharp]
using System;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;

public sealed class ConsoleReader
{
#region Interop
[StructLayout(LayoutKind.Sequential)]
private struct COORD
{
public short X;
public short Y;

public COORD(short X, short Y)
{
this.X = X;
this.Y = Y;
}
};

[DllImport("kernel32")]
static extern bool AllocConsole();

[DllImport("kernel32.dll")]
private static extern bool ReadConsoleOutputCharacter(IntPtr hConsoleOutput, [Out] char[] lpCharacter, uint nLength, COORD dwReadCoord, out uint lpNumberOfCharsRead);

[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GetStdHandle(int nStdHandle);

[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

[DllImport("user32.dll")]
private static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
#endregion

private IntPtr handle;
private char[] buffer;

public string Text { get; private set; }
public bool IsInitialized { get; private set; }

public ConsoleReader()
{
AllocConsole();
ShowWindow(FindWindow(null, Console.Title), 0); // Hides the console.

handle = GetStdHandle(-11); // -11 is the standard output stream. Odd number to use...
buffer = new char[Console.BufferWidth];

Text = "";

IsInitialized = true;
AppDomain.CurrentDomain.ProcessExit +=
(sender, e) => IsInitialized = false;
ThreadPool.QueueUserWorkItem(n => UpdateThread());
}

private void UpdateThread()
{
while (IsInitialized)
{
var textBuilder = new StringBuilder();

for (int i = 0; i < Console.BufferHeight; i++)
textBuilder.AppendLine(GetLine(i));

Text = textBuilder.ToString().TrimEnd();
}
}

private string GetLine(int line)
{
uint garbage;
if (!ReadConsoleOutputCharacter(handle, buffer, (uint)buffer.Length, new COORD(0, (short)line), out garbage))
throw new InvalidOperationException("Could not read console output.");

return new string(buffer).TrimEnd();
}
}
[/source]
And that's that.

Displaying the Text

I'll confess right now: I cheated. I used two textboxes, one for input and one for output. After spending a good length of time just trying to figure out how to read from the console, I was too lazy to have both input and output in one box. Sorry :P.

It's pretty straightforward from here. Place one textbox across the bottom, then another filling the rest of the space. Create a timer that retrieves the console input at a small interval. Check for any differences between the new and old text. Remember to scroll to the end if there are. Go take a nap.

Running Processes

Calling system isn't going to work here anymore, specifically for processes that ask for input while they run. Without any direct access to the console, how is the process supposed to receive input? Back to the Process class...

If you set UseShellExecute to false, but don't redirect the standard error and output streams, they will automatically show up in the console window. The input stream can be redirected without consequence.

I created a simple class for executing processes, and will redirect the command to any process already running.

[source lang=csharp]
using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;

public sealed class CommandRunner
{
private Process process;

public event EventHandler Finished;
public event EventHandler ExitCommandRecieved;

public void Execute(string command)
{
Console.WriteLine(command);

if (process != null)
SendToProcess(command);
else if (command.TrimStart().StartsWith("cd "))
ChangeDirectory(command);
else if ((command.Trim() + " ").StartsWith("exit "))
InvokeExit();
else
StartProcess(command);
}

private void InvokeExit()
{
if (ExitCommandRecieved != null)
ExitCommandRecieved(this, EventArgs.Empty);
}

private void ChangeDirectory(string command)
{
var directory = command.Split(new[] { ' ' }, 2)[1].Replace("\"", "");

if (Directory.Exists(directory))
Directory.SetCurrentDirectory(directory);
else
Console.WriteLine("The system cannot find the path specified.");

if (Finished != null)
Finished(this, EventArgs.Empty);
}

private void StartProcess(string command)
{
var filename = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "temp.bat");

File.WriteAllText(filename, "@echo off\n" + command);

process = new Process()
{
EnableRaisingEvents = true,
StartInfo = new ProcessStartInfo(filename) { UseShellExecute = false, RedirectStandardInput = true },
};

process.Exited +=
(sender, e) =>
{
process = null;
File.Delete(filename);

if (Finished != null)
Finished(null, EventArgs.Empty);
};

process.Start();
}

private void SendToProcess(string command)
{
process.StandardInput.WriteLine(command);
}
}
[/source]
And so I draw this mess to a close. That should cover everything necessary to emulate a console. With this out of the way I can get back to working on my normal graphics-related projects...

If there's anything that could be improved, in writing or in code, feel free to let me know.

YellPika
0 likes 2 comments

Comments

Gaiiden
Heh, cool stuff. I added in the link to your previous post. Always good to do this
August 08, 2011 11:22 PM
YellPika
Ah, thank you! I did that in my original entry, but forgot to put it back when I ported it here.
August 09, 2011 02:48 AM
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Profile
Author
Advertisement
Advertisement