Introduction
Sick of GDI+? Feel like you need a Physics degree to understand managed DirectX? Is the prospect of having to learn XAML making you queasy? Well, let's go back to a simpler time when you didn't have to worry about issues like marshalling code to the UI thread.
The Original Presentation Layer
Despite the prevalence of modern hi-resolution 2D and 3D interfaces, console applications still have their niche, and are still visible in lots of different application environments. Mainframe interfaces, retail POS systems, and remote monitoring applications are all examples of executables that don't require bleeding edge GUI interfaces. Time saved by not having to deal with idiosyncratic interface code is time that can be spent debugging or adding new functionality.
Since it is traditionally thought of as a platform for Windows and Web applications, version 1.1 of the .NET framework has very little support for working with the console. Outside of the very limited functionality of Console.WriteLine
and Console.ReadLine
, there were few ways for purely managed code to interact with the console. Many libraries were written that wrapped the Win32 API to achieve foreground and background colour changing and cursor positioning, but they were inconsistent and occasionally kludgy.
Even though this changed with version 2.0 of the framework, which supports colours, window sizing, and cursor positioning through managed code, it's still fairly difficult and repetitive to write code to draw interfaces on the console.
This isn't another article about how to get colour in your console apps, but deals with a higher abstraction. I'll show you how to supplement native .NET console functionality to organize your console interface declaratively into console "forms" that can be externalized from application code and referenced programmatically at run-time, like WinForms and WebForms, allowing you to build reusable interfaces quickly.
The Object Model
I'll give a brief outline of the major objects in the library, then talk about how they interact, with a little more detail.
There are five main classes, aside from the helper classes (like extended EventArgs
classes).
The root object is ConsoleForm
, which contains properties for Height
and Width
, Name
(used as the caption of the console window), and collections of Line
, Label
, and Textbox
objects the form manages. The ConsoleForm
object is the canvas on which UI elements are defined.
Line
objects are either LineOrientation.Horizontal
or LineOrientation.Vertical
, have a Location
property defining where on the console to begin drawing them, a Length
property defining how far right or down (depending on the orientation) the line will be drawn, and a Colour
property to specify what System.ConsoleColor
to use to draw the line.
Label
objects are read-only (from a UI point of view), and have Text
, Location
, and Length
properties. They also have Background
and Foreground
colour properties.
Textbox
objects are read/write, can be tabbed between, and accept keystrokes. They similarly have Text
, Location
, and Length
properties, but also include a PasswordChar
property to specify a mask character to implement password solicitation fields. Like Label
objects, they have Background
and Foreground
colour properties.
The Point
object is just a container for an X and a Y coordinate so console UI objects can have a Location
property.
In Action
ConsoleForm
objects are either created programmatically, or are deserialized from a file. All of the objects in this library implement IXmlSerializable
, and persist or de-persist themselves when pressed through the framework's XmlSerializer
objects. Once created, the data defined by the ConsoleForm
is drawn onto the console window when the Render()
method is called. By default, Render()
clears the screen before it draws the UI elements, but you can override that behaviour by passing false
as a parameter to the method call.
When Render()
is invoked, the ConsoleForm
resizes the console window and the window buffer (to avoid scroll bars) to the Height
and Width
it is given. It clears the screen (if requested), and begins to draw the UI elements.
Line
objects are drawn by positioning the cursor at the point on the screen defined by the Location
property, setting the Background
colour property of the console to the Foreground
colour of the line, and drawing a Length
number of spaces down or to the right.
Textbox
and Label
objects are both drawn the same way, and share a lot of functionality due to their common inheritance from the StdConsoleObject
class. The cursor is moved to the screen coordinates described by the Location
property; and a string is created from the Text
property to be displayed, either padded with spaces, or truncated to meet the Length
property of the field. It is then written with a Console.Write()
method call in the Foreground
and Background
colours of the StdConsoleObject
.
Once rendered, the ConsoleForm
object moves the cursor to the first Textbox
in the Textboxes
collection, and waits for a key press. Once a key press is received, the ConsoleForm
object decides what to do with it. One of several actions are possible:
- If the character is unprintable (a cursor key, function key, or control key), the
ConsoleForm
ignores it.
- If the character is [Enter], the
ConsoleForm
raises the FormComplete
event so the application can decide what to do next.
- If the character is [Esc], the
ConsoleForm
raises the FormCancelled
event so the application can decide what to do next.
- If the character is [Tab], the
ConsoleForm
advances the cursor to the next Textbox
in the Textboxes
collection. If the cursor was in the last Textbox
in the collection, the cursor is moved back to the first Textbox
. If [Shift] is held when [Tab] is pressed, the user can move through the Textbox
collection backwards.
- If the character is a [Backspace], a character is clipped from the end of the current
Textbox
(if one is available) and the cursor is backed up one space. A space in the colour of the background of the Textbox
is drawn where the backspaced character was.
- If any other character is pressed, and there is room left in the
Length
of the Textbox
, that character is drawn.
Only [Enter] and [Esc] cause the key press loop to be exited. They will cause the event wired to the FormComplete
or FormCancelled
, respectively, to be called with the state of the form. More on this later.
Since the key press solicitation is a blocking call to Console.Read()
, I create a new thread to wait for the key. Anything else your application is doing in the background will stay nice and responsive.
Declarative Sample
The demo project attached contains several sample console forms. The document below describes the login dialog screen for the LogReader
sample application:
<ConsoleForm Name="Login" Width="80" Height="30">
<Lines>
<Line Orientation="Horizontal" Length="40" Colour="Blue">
<Origin X="5" Y="5" />
</Line>
<Line Orientation="Vertical" Length="10" Colour="Blue">
<Origin X="44" Y="6" />
</Line>
<Line Orientation="Horizontal" Length="40" Colour="Blue">
<Origin X="5" Y="15" />
</Line>
<Line Orientation="Vertical" Length="10" Colour="Blue">
<Origin X="5" Y="6" />
</Line>
<Line Orientation="Horizontal" Length="40" Colour="DarkBlue">
<Origin X="6" Y="4" />
</Line>
<Line Orientation="Vertical" Length="10" Colour="DarkBlue">
<Origin X="45" Y="5" />
</Line>
</Lines>
<Labels>
<Label Name="lblLoginID" Text="Login ID:" Length="9"
ForeColour="White" BackColour="Black">
<Location X="9" Y="8" />
</Label>
<Label Name="lblPassword" Text="Password:" Length="9"
ForeColour="White" BackColour="Black">
<Location X="9" Y="9" />
</Label>
<Label Name="lblError" Length="30" ForeColour="Red"
BackColour="Black"> <Location X="9" Y="10" />
</Label>
<Label Name="lblInstructions1"
Text="Enter your user ID and password."
ForeColour="Yellow" BackColour="Black">
<Location X="9" Y="6" />
</Label>
<Label Name="lblInstructions2" Text="Hit [Enter] to login or"
ForeColour="Yellow" BackColour="Black">
<Location X="9" Y="12" />
</Label>
<Label Name="lblInstructions2" Text="[Esc] to quit."
ForeColour="Yellow" BackColour="Black">
<Location X="9" Y="13" />
</Label>
</Labels>
<Textboxes>
<Textbox Name="txtLoginID" Length="20"
ForeColour="DarkGreen" BackColour="White">
<Location X="20" Y="8" />
</Textbox>
<Textbox Name="txtPassword" Length="20"
ForeColour="DarkGreen" BackColour="White" PasswordChar="*">
<Location X="20" Y="9" />
</Textbox>
</Textboxes>
</ConsoleForm>
The first node under the ConsoleForm
node contains Line
object definitions, the second contains the Label
object definitions (the instructions and Textbox
identifiers), and the third node contains the Textbox
object definitions (including a password solicitation box). The Length
attribute is optional for Label
objects, and will be inferred from the length of the supplied Text
attribute, if it is not explicitly provided. The form definition above renders the following form:
Image 2: Login screen
The code that would actually draw this form at run-time and wait for its action is as follows:
CBOForm login =
CBO.ConsoleForm.GetFormInstance(@".\Forms\Login.xml",
new CBOForm.onFormComplete(login_Complete),
new CBOForm.onFormCancelled(login_Cancelled));
login.KeyPressed += new ConsoleForm.onKeyPress(login_KeyPressed);
login.Render();
This code creates a new form object, deserializes the form definition from the Login.xml file in the folder called "Forms" below the running executable, wires up the FormComplete
and FormCancelled
events to the login_Complete
and login_Cancelled
methods, respectively, wires up the KeyPressed
event, and displays the form. The user is then free to [Tab] around the form and enter data until they press [Enter] to have the library call the login_Complete
method, or until they press [Esc] to have the library call the login_Cancelled
method.
The signature of the method defined by the onFormCancelled
delegate is as follows:
private static void login_Cancelled(ConsoleForm sender,
EventArgs e) {
System.Environment.Exit(0);
}
In our case here, pressing [Esc] on the login form causes the application to exit.
The signature of the method defined by the onFormComplete
delegate looks like this:
private static void login_Complete(ConsoleForm sender,
FormCompleteEventArgs e) {
if (sender.Textboxes["txtLoginID"].Text == "sean" &&
sender.Textboxes["txtPassword"].Text == "murphy") {
ShowMainMenu();
} else {
sender.Labels["lblError"].Text = "Account not found.";
sender.Textboxes["txtLoginID"].Text = string.Empty;
sender.Textboxes["txtPassword"].Text = string.Empty;
sender.SetFocus(sender.Textboxes["txtLoginID"]);
e.Cancel = true;
}
}
This method looks at the contents of the two Textbox
objects, and does a simple test to validate the user. Obviously, you wouldn't hard-code credentials, but I wanted to focus on the essentials here. If the txtLoginID
Textbox
has the Text
property of "sean" and the txtPassword
Textbox
has the Text
property of "murphy", the main menu form is deserialized from disk, the FormComplete
event is wired (no FormCancelled
event is wired), and it is rendered.
If the credentials are not matched, the lblError
Label
is updated to show the source of the error, and the two Textbox
objects are cleared. The Cancel
property of the FormCompleteEventArgs
parameter is set to true
so that the library will cancel the disposal of the console form when the event returns. If the Cancel
property is not set (as it is where the credentials are matched), or is set explicitly to false
, the form will be disposed when the event returns. The key press loop thread will be terminated, and any attached events will be nullified in anticipation of another ConsoleForm
(Menu.xml in the example above) taking its place.
Each ConsoleForm
keeps track of whether it has been rendered or not. When you modify the contents of Label
and Textbox
objects, you may be altering a displayed form, or you may just be building up a new ConsoleForm
object in preparation for blitting it to the screen. If the form has been rendered, changes to the Text
property of Label
and Textbox
objects are reflected immediately on screen, as in the example above. If the form has not yet been rendered, changes to the Text
property do not go directly to the interface, and will only be shown after a call to Render()
.
The last event to examine is the one handled by the onKeyPress
delegate. In the login example, it is implemented like this:
static void login_KeyPressed(ConsoleForm sender, KeyPressEventArgs e) {
if (sender.Labels["lblError"].Text != string.Empty)
sender.Labels["lblError"].Text = string.Empty;
}
If this event is wired, the event handler gets the first crack at examining the key pressed by the user, and can decide whether to cancel the key press or take some other action. In our example, we're using any key press to clear a displayed error, if there is one. If you were interested in specific keystrokes, you could examine the Char
property of the KeyPressEventArgs
parameter, and set the Cancel
property of the same parameter to true
if you wanted the form engine to ignore the key press. Cancel
will prevent processing of any key press, including [Enter] and [Esc], which would have otherwise transitioned the application from that form.
Programmatic Example
You're not restricted to externally defined forms, though. You can build up console forms with code, in addition to deserializing them from disk. The following example builds up the main menu for the sample application:
private static void ShowMainMenu() {
CBOForm menuForm = new CBOForm(80, 30);
menuForm.Name = "Main Menu";
Label lblTitle = new Label("lblTitle",
new Point(1, 2),
10,
"Main Menu",
ConsoleColor.Green,
ConsoleColor.Black);
Label lblBrowse = new Label("lblBrowse",
new Point(4, 4),
10,
"1. Browse");
Label lblRefresh = new Label("lblRefresh",
new Point(4, 5),
16,
"2. Refresh Array");
Label lblExit = new Label("lblExit",
new Point(4, 12),
10,
"9. Exit");
Label lblChoice = new Label("lblChoice",
new Point(4, 14),
2,
">>",
ConsoleColor.Yellow,
ConsoleColor.Black);
Label lblError = new Label("lblError",
new Point(4, 16),
40,
string.Empty,
ConsoleColor.Red,
ConsoleColor.Black);
Textbox txtInput = new Textbox("txtInput",
new Point(6, 14),
1,
string.Empty);
menuForm.Labels.Add(lblTitle);
menuForm.Labels.Add(lblBrowse);
menuForm.Labels.Add(lblRefresh);
menuForm.Labels.Add(lblExit);
menuForm.Labels.Add(lblChoice);
menuForm.Labels.Add(lblError);
menuForm.Textboxes.Add(txtInput);
menuForm.FormComplete += new ConsoleForm.onFormComplete(MenuSelection);
menuForm.Render();
}
The sample application also shows how to display a form attached to a timer. I said before that there are only two ways "out" of a form, FormComplete
and FormCancelled
, but you can also terminate forms externally in response to other application events. You just have to make sure you have a handle to the form so you can Dispose()
it and terminate the key press thread, or the next form you display may not receive the keystrokes it expects.
Notes
Alternatives
I am aware of some other mechanisms that could be used similarly, like Lynx and NCurses. I chose to write my own library because I wanted a cut-down declarative definition syntax and a very simple object model. Both Lynx and NCurses are vastly powerful, and usually overkill for nice little interfaces I wanted to throw up that consist of labels, lines, textboxes, and reacting to individual key strokes.
A Sermon on Cancellable Events
The delegates for both the FormComplete
and KeyPressed
events include classes derived from System.EventArgs
that include a boolean property called Cancel
that allows code in event handlers to send a message back to the library code, that initially invoked the delegate, to inform it that some action should not be taken. This is similar to the FormClosing
event handler for Windows Forms that allows you to cancel the close operation.
Events are usually open to as many subscribers as you want, but in the case of cancellable events, I don't think this makes sense. If there are multiple listeners to FormClosing
, some of which are setting Cancel
to true
and others setting it to false
, the only vote that counts is the last one. The event code has no way of "knowing" whether it is the last in the chain of events and whether its opinion about the state of the Cancel
property will be honoured.
For that reason, I only allow one subscriber to my events that contain cancellable properties. This can be enforced this way:
private onKeyPress _keyPressEvent = null;
public event onKeyPress KeyPressed {
add {
if (_keyPressEvent == null)
_keyPressEvent = value;
else
throw new InvalidOperationException(
"Can only wire 1 handler to this event.")
}
remove {
if (_keyPressEvent == value)
_keyPressEvent = null;
else
throw new InvalidOperationException("You can't unhook an unwired event.");
}
}
Declare a delegate variable, and include the explicit add{}
code with the event declaration. If there are no listeners, it allows the client code to add one. If the delegate is not null though, an event is already wired, so raise an InvalidOperationException
to spank the coder. It prevents clients from doing this:
login.KeyPressed += new ConsoleForm.onKeyPress(login_KeyPressed);
login.KeyPressed += new ConsoleForm.onKeyPress(someOtherEventHandler);
It compiles, but will generate a runtime exception when the second assignment is hit.
If you include the add{}
handler, the compiler makes you include an explicit remove{}
handler, which allows me to enforce one of my pet peeves. I hate how the framework allows you to unsubscribe from events to which you did not subscribe. Even though the code executes without complaint, if I'm unsubscribing something that wasn't wired to begin with, I want to know about it as it probably indicates a lapse in judgment. The code in the remove{}
block above will only allow you to unsubscribe from the subscribed event. If you attempt to unsubscribe any other event, a run-time error will occur. It prevents this:
login.KeyPressed += new ConsoleForm.onKeyPress(login_KeyPressed);
login.KeyPressed -= new ConsoleForm.onKeyPress(someOtherEventHandler);
Image 3: Futuristic splash screen
Conclusion
The computing power that is going to be required by Vista is frankly embarrassing, and will mainly go unused except for the horsepower required to drive the interface. 98% of the applications out there should not require dual-core CPUs and $600 video cards. They almost certainly don't require sheared and rotated combo boxes.
Since the current trend in computing is the dumping down of the client with web services and AJAX, I thought I'd contribute a little bit of code to help simplify interface creation and management at run-time. It doesn't get any simpler than the console, and I hope I've made it simpler still.
Now, go knock off some good looking console apps, and show Redmond that interfaces don't need high power hardware to be functional and attractive.
Share and enjoy.
History
- August 7 2006 - Initial revision.
- August 11 2006 - Fixed a bug in the
Render()
method that caused an error trying to restart the key press loop if a form was being re-used.