Click here to Skip to main content
15,860,844 members
Articles / Programming Languages / XML
Article

Forms for Console Apps

Rate me:
Please Sign up or sign in to vote.
4.88/5 (44 votes)
7 Aug 2006CPOL12 min read 115.4K   3K   125   22
A framework for employing reusable, declarative interfaces for console applications.

Sample Image - FFCA01.png

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:

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

Login Screen

Image 2: Login screen

The code that would actually draw this form at run-time and wait for its action is as follows:

C#
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:

C#
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:

C#
private static void login_Complete(ConsoleForm sender, 
                                   FormCompleteEventArgs e) {
   if (sender.Textboxes["txtLoginID"].Text == "sean" &&
         sender.Textboxes["txtPassword"].Text == "murphy") {
      // User validated. Show main menu

      ShowMainMenu();
   } else {
      // Account not found.

      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; // Keep the form visible. Don't Dispose() it.

   }
}

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:

C#
static void login_KeyPressed(ConsoleForm sender, KeyPressEventArgs e) {
   // If an error was displayed, clear it on this keypress.

   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:

C#
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:

C#
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:

C#
login.KeyPressed += new ConsoleForm.onKeyPress(login_KeyPressed);
login.KeyPressed += new ConsoleForm.onKeyPress(someOtherEventHandler); // Bonk.

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:

C#
login.KeyPressed += new ConsoleForm.onKeyPress(login_KeyPressed);
login.KeyPressed -= new ConsoleForm.onKeyPress(someOtherEventHandler); // Bang. Error.

Login Screen

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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Technical Lead
Canada Canada
I'm a graduate of the University of Toronto with a degree in zoology. I'm currently a software development manager with a large Canadian financial institution, and a passionate squash player.

I am a proud daddy to Alex and Sarah.

Comments and Discussions

 
SuggestionDeclarative Example Outdated Pin
Member 1252482513-Sep-16 7:18
Member 1252482513-Sep-16 7:18 
Questiongeneric rendering Pin
filmee247-May-16 3:33
filmee247-May-16 3:33 
QuestionI dont want loginID just the possword Pin
SKalpanaH18-Oct-13 9:43
SKalpanaH18-Oct-13 9:43 
QuestionMulti-line Text Pin
Shawn Hinsey6-Aug-11 17:48
Shawn Hinsey6-Aug-11 17:48 
AnswerRe: Multi-line Text Pin
Sean Michael Murphy15-Aug-11 8:58
Sean Michael Murphy15-Aug-11 8:58 
GeneralRe: Multi-line Text Pin
juan_burjo18-Jan-12 10:19
juan_burjo18-Jan-12 10:19 
GeneralMy vote of 5 Pin
fraser12531-Jul-11 10:29
fraser12531-Jul-11 10:29 
GeneralSimply WOW Pin
SevenMedison23-Sep-08 7:23
SevenMedison23-Sep-08 7:23 
GeneralGreat! Pin
Michael Streif14-Aug-08 12:09
Michael Streif14-Aug-08 12:09 
GeneralVery useful. Pin
Septimus Hedgehog16-Jul-08 0:59
Septimus Hedgehog16-Jul-08 0:59 
GeneralRe: Very useful. Pin
Sean Michael Murphy17-Jul-08 7:56
Sean Michael Murphy17-Jul-08 7:56 
Generalthe good old days! Pin
TheCardinal10-Jun-08 20:03
TheCardinal10-Jun-08 20:03 
GeneralThank's ! Pin
azraelangel20-Nov-07 7:54
azraelangel20-Nov-07 7:54 
GeneralNice! Pin
robixdf11-Feb-07 23:04
robixdf11-Feb-07 23:04 
GeneralRe: Nice! Pin
Sean Michael Murphy12-Feb-07 15:19
Sean Michael Murphy12-Feb-07 15:19 
GeneralNo replacement for a true UI... [modified] Pin
Overboard Software15-Aug-06 12:02
Overboard Software15-Aug-06 12:02 
GeneralRe: No replacement for a true UI... Pin
Sean Michael Murphy16-Aug-06 7:58
Sean Michael Murphy16-Aug-06 7:58 
GeneralDeclarative programming Pin
Marc Clifton9-Aug-06 3:05
mvaMarc Clifton9-Aug-06 3:05 
GeneralNifty Pin
The_Mega_ZZTer8-Aug-06 3:32
The_Mega_ZZTer8-Aug-06 3:32 
GeneralRe: Nifty Pin
Sean Michael Murphy8-Aug-06 4:40
Sean Michael Murphy8-Aug-06 4:40 
GeneralYour Demo is missing Splash.xml Pin
Mortman7-Aug-06 9:34
Mortman7-Aug-06 9:34 
GeneralRe: Your Demo is missing Splash.xml Pin
Sean Michael Murphy7-Aug-06 13:30
Sean Michael Murphy7-Aug-06 13:30 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.