Freigeben über


Basic text-based console UI implemented in F#

This is a brief exploration of some of the capabilities of the F# language to define a small set of classes that can be leveraged to provide a simple text-based UI for an F# program. Example code is provided, which is a great introduction to the syntax, but a gentler and more thorough introduction to F# can be found at MSDN Visual F#.

After learning more about the language from a series of F# videos, I decided to explore the language and tinker with it for a while. One of the first challenges a developer will face working with a new language is settling on a simple means to display results to the screen and get input from the user. F# can import .NET assemblies, which are certainly sufficient, but working on a basic text console it turned out to be an interesting way to explore the language. Admittedly, this is not a problem domain specifically suited for F#, but it exercises some of the fundamental capabilities of the language and is grounded in the familiar territory of the command line console.

Box Console Text UI

Here’s a screenshot of what the final text console test program looks like, which renders its interface using the F# BoxConsole module.

image

Each of the boxes shown represents an instance of one of the classes defined in the BoxConsole module using structures to define the box location, size and style. The menu box in the center is an example of a DosMenu class which combines all of the building block classes to implement an F# application menu.

F# Enumerations & Simple Data Structures

One of the fundamental affordances a programming language can provide is a mechanism to map enumerated integer values to a set of constant symbols, a.k.a. an Enumeration. F# employs it’s pattern matching syntax to define an enumeration and here are some examples from the BoxConsole module:

BoxConsole.fs

  1. ///////////////////////////////////////////////////////////////////////////////
  2. // Enumerates the four corners of a box.
  3. ///////////////////////////////////////////////////////////////////////////////
  4. type BoxCorner =
  5.     | UL = 0
  6.     | UR = 1
  7.     | LR = 2
  8.     | LL = 3
  9.  
  10. ///////////////////////////////////////////////////////////////////////////////
  11. // Enumerates possible text alignments.
  12. ///////////////////////////////////////////////////////////////////////////////
  13. type TextAlignment =
  14.     | Left = 0
  15.     | Center = 1
  16.     | Right = 2

The first enumeration, BoxCorner expresses a shorthand to refer to the upper left, upper right, lower right and lower left corners of a box and the TextAlignment enumeration allows the users of the BoxConsole module to specify a left, center or right text justification.

Another fundamental programming language expression is the data record or structure that contains a collections of related values. The BoxLocation type defines a F# data structure to collect the data describing the size and position of a box.

BoxConsole.fs

  1. ///////////////////////////////////////////////////////////////////////////////
  2. // Contains box top, left, width and height information.
  3. ///////////////////////////////////////////////////////////////////////////////
  4. type BoxLocation =
  5.     { Top : int;
  6.       Left : int;
  7.       Width : int;
  8.       Height : int; }

The BoxStyle type describes the style of a DosBox, including members that describe the border style, margin around the text along with foreground and background colors of the box. In the case of the BoxStyle type, the Border member refers to an object and not a simple data type.

BoxConsole.fs

  1. ///////////////////////////////////////////////////////////////////////////////
  2. // Structure describing the syle of a DOS box.
  3. ///////////////////////////////////////////////////////////////////////////////
  4. type BoxStyle =
  5.     { Border : BorderStyle;
  6.       Margin : int;
  7.       Foreground : System.ConsoleColor;
  8.       Background : System.ConsoleColor; }

 

Mutable Values and Classes

The “immutable by default” nature of F# encourages developers to craft code that the complier can optimize much more aggressively and runs faster in a parallel execution context. However, at the end of the day, the program must have a reasonable way to express it’s state. The mutable keyword is the signal to F# that the variable being declared will be used in the more traditional manner: its value should be stored and can be expected to change during program execution. These mutable variables can then be used as fields to store values for the implementation of a class.

BoxConsole.fs

  1. ///////////////////////////////////////////////////////////////////////////////
  2. // Implements various styles for drawing boxes.
  3. ///////////////////////////////////////////////////////////////////////////////
  4. type BorderStyle = class
  5.  
  6.     ///////////////////////////////////////////////////////////////////////////////
  7.     // Defines a number of text patterns used to style the boxes. Each pattern is
  8.     // nine chracters in length and define the corner, edge and background characters.
  9.     ///////////////////////////////////////////////////////////////////////////////
  10.     static member private boxPatterns =
  11.         @" /-\\-/|.| " +
  12.         @"*-**-*| |+-++-+| | +++++++ +******* *////////////////\/ " +
  13.         @" " +
  14.         @"====== vvv^^^ ~~~~~~) (XXXXXXX X + + + + " +
  15.         @" oooo uuu "
  16.  
  17.     ///////////////////////////////////////////////////////////////////////////////
  18.     // Returns the specified set of nine-character box styles.
  19.     ///////////////////////////////////////////////////////////////////////////////
  20.     static member private getPattern n =
  21.         new String [| for i in 0 .. 8 -> BorderStyle.boxPatterns.[9*n + i] |]
  22.  
  23.     ///////////////////////////////////////////////////////////////////////////////
  24.     // Determine start, middle or end style character for line of a specific size.
  25.     ///////////////////////////////////////////////////////////////////////////////
  26.     static member private boxPart size n =
  27.         match n with
  28.         | 1               -> 0
  29.         | n when n = size -> 2
  30.         | _               -> 1
  31.  
  32.     ///////////////////////////////////////////////////////////////////////////////
  33.     // Given a box style pattern, render a box of the specified size.
  34.     ///////////////////////////////////////////////////////////////////////////////
  35.     static member BoxLines(width : int, height : int, pattern : string) =
  36.         [ for h in [1..height] ->
  37.             new System.String
  38.                 [| for w in 1 .. width ->
  39.                     match h, (BorderStyle.boxPart width w) with
  40.                     | 1, i                 -> pattern.[i]
  41.                     | _, i when h = height -> pattern.[i + 3]
  42.                     | _, i                 -> pattern.[i + 6] |] ]
  43.  
  44.     ///////////////////////////////////////////////////////////////////////////////
  45.     // Pattern in use for this border style.
  46.     ///////////////////////////////////////////////////////////////////////////////
  47.     val mutable Pattern : string;
  48.  
  49.     ///////////////////////////////////////////////////////////////////////////////
  50.     // Constructor to use one of the predefined style patterns.
  51.     ///////////////////////////////////////////////////////////////////////////////
  52.     new (style: int) as border =
  53.         { Pattern = " "; }
  54.         then
  55.             if style <= BorderStyle.boxPatterns.Length / 9
  56.                 then border.Pattern <- (BorderStyle.getPattern style)
  57.  
  58.     ///////////////////////////////////////////////////////////////////////////////
  59.     // Constructor to use a custom nine-character style pattern.
  60.     ///////////////////////////////////////////////////////////////////////////////
  61.     new (pattern : string) as border =
  62.         { Pattern = " "; }
  63.         then
  64.             if pattern.Length >= 9 then border.Pattern <- pattern
  65.  
  66. end

(Note: Due to encoding issues, the contents of boxPatterns are not correctly rendered above. To see the complete set of character used, please download the project source code .)

Those new to F# but familiar with today’s object-oriented patterns should be able to recognize those patterns peeking through this alien code if they “squint” just a little bit. The static keyword is used to express functionality within the class scope, and is omitted for instance members of a class. Creation of objects is achieved via the new keyword, which also used to declare class constructors. Also notice the use of the private keyword allowing the developer to control the visibility of members in order to help maintain encapsulation.

The BorderStyle class knows how to render a nine character array as a the corners and edges of a box. One constructor uses a predefined style and the other allows the caller to specify a custom set of nine characters to define a border style. This class is effectively implemented with a couple pattern matching methods.

The first, boxPart, matches determines which offset to use to (0-2) depending on if it’s the first item in the list (yields 0), the last item in the list (2), or any other item in the list (1). Similar matching rules are employed in the BoxLinesfunction, but that pattern is wrapped in an array [| for w in 1 .. width –> |] generated from a list [ for h in [1..height] –> ] . Together, these two functions provide the character patterns for the box’s border style given a width and height.

Using the System.Console class

Class ConsoleUI wraps the System.Console class for the BoxConsole module, making the console easier to use from the F# implementation. F# uses the open keyword to import other type libraries into the current program scope. In this case we import the System .NET module on line six, right after we explicitly declare the name of our module on line four.

BoxConsole.fs

  1. ///////////////////////////////////////////////////////////////////////////////
  2. // BoxConsole.fs - Basic text-based console UI implemented in F#
  3. ///////////////////////////////////////////////////////////////////////////////
  4. module BoxConsole
  5.  
  6. open System

Taking a closer look at one of the ConsoleUI methods reveals how .NET methods and properties can be called from an F# application. The WriteAt function will write a message on the console in the requested location and will trim the message to prevent the console screen from scrolling.

BoxConsole.fs

  1. ///////////////////////////////////////////////////////////////////////////////
  2. // Writes the message at the given location. This method will trim the message
  3. // to pervent scrolling of the console window.
  4. ///////////////////////////////////////////////////////////////////////////////
  5. static member WriteAt message left top =
  6.     let oleft = System.Console.CursorLeft
  7.     let otop = System.Console.CursorTop
  8.  
  9.     let message = ConsoleUI.trimToPreventOverflow(message, left, top)
  10.  
  11.     Console.CursorLeft <- left
  12.     Console.CursorTop <- top
  13.  
  14.     Console.Write(message : string)
  15.  
  16.     Console.CursorLeft <- oleft
  17.     Console.CursorTop <- otop

Note the use of the variables oleft and otop which are used to store the current cursor position so it can be restored once the message is written. These variables are written to once and never modified. They are given their values using the let keyword and the equals (=) sign and they are immutable, the value is bound that that name. Even in the case of message, the value is is not mutable: the old value of message is lost and a new one (possibly shorter) is created.

On the other hand, the .NET system Console.CursorLeft property is not immutable and must be modified to change the cursor position. In this case F# considers the variable mutable and allows its value to be changed using the <- operator. Finally, the WriteAt function provides an example of calling a method on a .NET class, writing the message to the console using the Console.Write(message: string) syntax to invoke the method.

Example Program

Program.fs is provided as a practical example of how to use the BoxConsole module to display and process user selections using the DosMenu class, and allows the user to cycle through some of the various styles and options available.

Program.fs

  1. ///////////////////////////////////////////////////////////////////////////////
  2. // Program.fs - Console UI Test Application
  3. ///////////////////////////////////////////////////////////////////////////////
  4. module Program
  5.  
  6. open BoxConsole
  7. open System
  8.  
  9. // Define a menu to drive the program
  10. let m = DosMenu(["Color"; "Style"; "Singleline"; "Multiline"; "Align"; "Margin"; "Quit"], 3)
  11.  
  12. // Collections of colors to use for foreground and background testing
  13. let bgcs = [ ConsoleColor.DarkBlue; ConsoleColor.DarkMagenta; ConsoleColor.DarkRed; ConsoleColor.DarkGray; ConsoleColor.Black ]
  14. let fgcs = [ ConsoleColor.Yellow;   ConsoleColor.Magenta;     ConsoleColor.Red;     ConsoleColor.White;    ConsoleColor.White ]
  15.  
  16. Console.Clear()
  17.  
  18. let mutable color = 0         // Currently selected color
  19. let mutable style = 0         // Currently selected style
  20. let mutable singleline = true // Single line/multi-line text
  21. let mutable displayText = []  // Currently displayed text
  22. let mutable align = 0         // Currently selected text alignment
  23. let mutable margin = 1        // Currently selected style margin
  24.  
  25.     let mutable selection = ""// User menu selection
  26.  
  27. // Create a text box to contain current date / time, center horizontally.
  28. let timeBox = new DosBox([System.DateTime.Now.ToLongTimeString(); System.DateTime.Now.ToLongDateString(); ])
  29. timeBox.Location <- { Top = Console.WindowHeight - 5; Left = timeBox.Location.Left; Width = timeBox.Location.Width + 2; Height = timeBox.Location.Height }
  30. timeBox.BoxText.Align <- TextAlignment.Center
  31. timeBox.BoxText.Foreground <- ConsoleColor.Green;
  32. timeBox.BoxText.Background <- ConsoleColor.DarkGreen;
  33. timeBox.Style <- { Border = new BorderStyle(3); Foreground = ConsoleColor.Green; Background = ConsoleColor.DarkGreen; Margin = 1; }
  34.  
  35. // Main program loop
  36. while selection <> "Quit" do
  37.  
  38.     // Check if user made a valid selection
  39.     if selection <> "" then
  40.         Console.Clear()
  41.  
  42.         // Update current state based on the user's selection
  43.         match selection with
  44.         | "Color" -> color <- color + 1
  45.         | "Style" -> style <- style + 1
  46.         | "Singleline" -> singleline <- true
  47.         | "Multiline" -> singleline <- false
  48.         | "Align" -> align <- align + 1
  49.         | "Margin" -> margin <- margin + 1
  50.         | _ -> ()
  51.  
  52.         // Update each of the corner boxes
  53.         for x in (int BoxCorner.UL)..(int BoxCorner.LL) do
  54.             let k = (x+color)%5
  55.  
  56.             // Choose single line or multi-line text
  57.             let cornerDisplay =
  58.                 if singleline then
  59.                     displayText <- [selection]
  60.                 else
  61.                     displayText <- ["---------------"; selection; "---------------";]
  62.  
  63.             // Create the box in the appropriate corner
  64.             let box = new DosCornerBox(displayText,enum<BoxCorner>(x))
  65.             box.Style <- { Border = new BorderStyle (style%35); Foreground = fgcs.[k]; Background = bgcs.[k]; Margin = (margin % 4 + 1); }
  66.             box.BoxText.Foreground <- fgcs.[k]
  67.             box.BoxText.Background <- bgcs.[k]
  68.             box.BoxText.Align <- enum<TextAlignment>((int align) % 3)
  69.             box.RecalcBox()
  70.             box.Draw()
  71.  
  72.     // Select current foreground/background color combination
  73.     let v = (color+4)%5
  74.     m.Style <- { Border = new BorderStyle (style%35); Foreground = fgcs.[v]; Background = bgcs.[v]; Margin = (margin % 5); }
  75.     m.Box.BoxText.Foreground <- fgcs.[v]
  76.     m.Box.BoxText.Background <- bgcs.[v]
  77.  
  78.     // Update the current date and time
  79.     timeBox.BoxText.Text <- [System.DateTime.Now.ToLongTimeString(); System.DateTime.Now.ToLongDateString(); ]
  80.     timeBox.Draw()
  81.  
  82.     // Wait for user selection, or timeout in 1s
  83.     selection <- m.GetSelectionTimeout 1000
  84.     ()

The example first declares the module name and imports the System and BoxConsole types. The program then creates the DosMenu containing a list of the box styles through which the user can cycle. Next the program declares a set of working variables. It’s interesting to note that like many other program there is a mix of immutable and mutable variables, but in F# it’s mutable variables that are the exception that require additional semantic notation.

Next the program creates a DosBox that displays the time and then enters the main program while loop where it will run until the user selects “Quit”. In the body of the main loop, if the user has made a selection then the program options are updated. Finally, the program will pause for 1000ms to wait for the user’s next selection from the DosMenu.

Download complete BoxConsole source code.