Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Generate a WPF Frontend from a Console Application

0.00/5 (No votes)
25 Oct 2016 1  
How to structure a command line application so that as it is modified, the GUI automatically changes.

Introduction

This article discusses the implementation of a minimal WPF frontend on a C# command line application. The specific example started life as a Visual Studio extension, testing of such requires building a VSIX file and then launching a special instance of Visual Studio - both of which are rather time consuming. Given that the GUI was limited to choosing files, a command line application was sensible for both testing and for general use. The structure of the application is given by:

Assembly structure

Here, the Console .NET assembly has been expanded to show its constituent classes. All the assemblies linked by Console do so via its View class, the GUI assembly accesses all of the Console executables classes apart from the rather unimaginatively named Program class which provides the main() function.

The Command Line

Running the command line program with no arguments prints the help. The help is constructed dynamically and the first few lines of the routine to do so are:

var ordered = Options.Ordered();
var options = Options.Defaults();
var help = Options.Help();

var splitter = new Splitter(System.Console.Out, System.Console.WindowWidth);

splitter.WriteLine
(string.Empty, "DeepEnds command line application for batch execution");

The Splitter class is initialized with a buffer and a line length, it has one method which takes an indent and the line to write. The Options class consists of an enumerant and several static methods which return key-value mappings where the key is a string denoting an argument. The Ordered static method returns an array of keys, the last of which happens to be non-optional. The array of keys is iterated over twice; firstly to construct the form of the command line and then to detail the arguments (their default values and their specific help).

When arguments are specified on the command line, they overwrite the default values and are then passed through to the View class for processing.

As new options are added to the program, it is expected that only the Options class needs to be modified. These extra keys are then automatically accounted for by both the command line and GUI versions of the program.

The GUI

The XAML for the dialogue box is concisely given by:

<UserControl x:Class="DeepEnds.GUI.DeepEndsControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             Background="{DynamicResource VsBrush.Window}"
             Foreground="{DynamicResource VsBrush.WindowText}"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300"
             Name="MyToolWindow">
    <ScrollViewer HorizontalScrollBarVisibility="Auto" 
    VerticalScrollBarVisibility="Auto">
        <Grid x:Name="grid">
            <Grid.RowDefinitions>
                <RowDefinition Height="50" />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition />
                <ColumnDefinition Width="50" />
            </Grid.ColumnDefinitions>
            <Button Grid.Row="0" Grid.Column="0" 
            Content="Command" Click="Command_Click" 
             Name="CommandButton" 
             ToolTip="Display the batch file command"/>
            <Button Grid.Row="0" 
            Grid.Column="1" Content="Execute" 
            Click="Execute_Click" 
             Name="ExecuteButton" 
             ToolTip="Execute the batch file command"/>
        </Grid>
    </ScrollViewer>
</UserControl>

i.e., a grid of 1 row and 3 columns, where the first two columns contain buttons. This results in the following appearing on the screen.

The GUI

The additional rows are constructed from the ordered keys, the keys themselves appearing as labels on the instances of TextBlock in the first column. The second column allows the value associated with the key to be specified in an instance of a TextBox. The final column contains a button if a mapping (from key to an enumerant specifying whether to save a file or open a file or a directory) is returned from another static method on the Options class. Finally, each item on the row has a tooltip given by running the appropriate item from the Help() mapping through the Splitter class with no indent and a length of 80.

private View view;
private Dictionary<string, string> options;
private Dictionary<string, Options.Browse> types;
private Dictionary<string, string> filters;
private Dictionary<string, TextBox> values;

public DeepEndsControl()
{
    this.InitializeComponent();

    this.view = new View();
    this.options = Options.Defaults();
    this.types = Options.Types();
    this.filters = Options.Filters();
    this.values = new Dictionary<string, TextBox>();

    var help = Options.Help();
    int row = 0;

    var ordered = Options.Ordered();
    foreach (var key in ordered)
    {
        ++row;

        var split = new System.IO.StringWriter();
        var splitter = new Splitter(split, 80);
        splitter.WriteLine(string.Empty, help[key]);
        var toolTip = split.ToString();

        var def = new RowDefinition();
        this.grid.RowDefinitions.Add(def);
        if (key == "filenames")
        {
            def.MinHeight = 20.0;
        }
        else
        {
            def.Height = new GridLength(10.0, GridUnitType.Auto);
        }

        var label = new TextBlock();
        label.Name = key;
        label.ToolTip = toolTip;
        label.Text = key;
        label.Margin = new Thickness(10.0);
        label.HorizontalAlignment = HorizontalAlignment.Left;
        Grid.SetColumn(label, 0);
        Grid.SetRow(label, row);
        this.grid.Children.Add(label);

        var value = new TextBox();
        value.Name = key;
        value.ToolTip = toolTip;
        value.Text = this.options[key];
        value.MinWidth = 120.0;
        value.TextChanged += this.Value_TextChanged;
        Grid.SetColumn(value, 1);
        Grid.SetRow(value, row);
        this.grid.Children.Add(value);
        this.values[key] = value;
        if (key == "filenames")
        {
            value.AcceptsReturn = true;
            value.TextWrapping = TextWrapping.Wrap;
        }

        if (this.types.ContainsKey(key))
        {
            var browse = new Button();
            browse.Name = key;
            browse.ToolTip = toolTip;
            browse.Content = "Browse...";
            browse.Click += this.Browse_Click;
            Grid.SetColumn(browse, 2);
            Grid.SetRow(browse, row);
            this.grid.Children.Add(browse);
        }
    }
}

When a browse button is pressed, the subsequent event is handled by a function that firstly queries the caller for its name which happens to be the key. This is then used to access the mapping that states what type of dialogue to launch - file open, file save or select directory. For files, there is an additional mapping method on Options that returns the file filter. When the user has set the path, the corresponding TextBox has its value changed appropriately.

private void Browse_Click(object sender, RoutedEventArgs e)
{
    var name = ((System.Windows.Controls.Button)e.Source).Name;

    var type = this.types[name];
    if (type == Options.Browse.fileOut)
    {
        var dlg = new System.Windows.Forms.SaveFileDialog();
        dlg.Filter = this.filters[name];
        var result = dlg.ShowDialog();
        if (result == System.Windows.Forms.DialogResult.OK)
        {
            this.values[name].Text = dlg.FileName;
        }
    }
    else if (type == Options.Browse.fileIn)
    {
        var dlg = new System.Windows.Forms.OpenFileDialog();
        dlg.Filter = this.filters[name];
        dlg.Multiselect = true;
        var result = dlg.ShowDialog();
        if (result == System.Windows.Forms.DialogResult.OK)
        {
            var selection = this.values[name].Text;
            foreach (var item in dlg.FileNames)
            {
                selection += string.Format("{0}\n", item);
            }

            this.values[name].Text = selection;
        }
    }
    else if (type == Options.Browse.directoryIn)
    {
        var dlg = new System.Windows.Forms.FolderBrowserDialog();
        var result = dlg.ShowDialog();
        if (result == System.Windows.Forms.DialogResult.OK)
        {
            this.values[name].Text = dlg.SelectedPath;
        }
    }
}

If a TextBox has its value changed, the subsequent event is again queried for the key value. This is then used to overwrite the value in a mapping that was initialised with the default values.

private void Value_TextChanged(object sender, TextChangedEventArgs e)
{
    var name = ((System.Windows.Controls.TextBox)e.Source).Name;
    this.options[name] = this.values[name].Text;
}

As the buttons in the first row are pressed, their subsequent output needs to be displayed. This is handled by writing to the output pane within Visual Studio itself.

// See https://mhusseini.wordpress.com/2013/06/06/write-to-visual-studios-output-window/
public class OutputPane : System.IO.TextWriter
{
    private Microsoft.VisualStudio.Shell.Interop.IVsOutputWindowPane pane;

    public OutputPane()
    {
        var outputWindow = Microsoft.VisualStudio.Shell.Package.GetGlobalService
        (typeof(Microsoft.VisualStudio.Shell.Interop.SVsOutputWindow)) 
        as Microsoft.VisualStudio.Shell.Interop.IVsOutputWindow;
        var paneGuid = new System.Guid("c3296bde-a1a4-4157-aad5-b344de40d936");
        outputWindow.CreatePane(paneGuid, "DeepEnds", 1, 0);
        outputWindow.GetPane(paneGuid, out this.pane);
    }

    public void Show()
    {
        EnvDTE.DTE dte = Microsoft.VisualStudio.Shell.Package.GetGlobalService
        (typeof(Microsoft.VisualStudio.Shell.Interop.SDTE)) as EnvDTE.DTE;
        dte.ExecuteCommand("View.Output", string.Empty);
        this.pane.Clear();
    }

    public override Encoding Encoding
    {
        get
        {
            return Encoding.ASCII;
        }
    }

    public override void Write(string value)
    {
        this.pane.OutputString(value);
    }

    public override void WriteLine(string value)
    {
        this.pane.OutputString(value);
        this.pane.OutputString("\n");
    }
}

In the case of the Execute button, the output pane contains the output that would result from running the command line application. When the Command button is pressed, the output pane contains the text required to run the command line application; optional arguments which contain the default values are omitted.

private void Execute_Click(object sender, RoutedEventArgs e)
{
    try
    {
        if (this.pane == null)
        {
            this.pane = new OutputPane();
        }

        this.pane.Show();
        this.view.Read(this.pane, this.options, 
        this.options["filenames"].Split(new char[] { '\n' }, 
                       System.StringSplitOptions.RemoveEmptyEntries));
        this.view.Write(this.pane, this.options);
    }
    catch (System.Exception excep)
    {
        MessageBox.Show(excep.Message, 
        "DeepEnds", MessageBoxButton.OK, MessageBoxImage.Error);
    }
}

private void Command_Click(object sender, RoutedEventArgs e)
{
    try
    {
        if (this.pane == null)
        {
            this.pane = new OutputPane();
        }

        this.pane.Show();
        var file = this.pane;

        var message = 
        System.Reflection.Assembly.GetAssembly(typeof(View)).Location;
        if (message.Contains(" "))
        {
            file.Write("\"");
            file.Write(message);
            file.Write("\"");
        }
        else
        {
            file.Write(message);
        }

        var defaults = Options.Defaults();
        var ordered = Options.Ordered();
        foreach (var key in this.options.Keys)
        {
            if (!ordered.Contains(key) || key == "filenames")
            {
                continue;
            }

            var val = this.options[key];
            if (val == defaults[key])
            {
                continue;
            }

            file.Write(" ");
            file.Write(key);
            file.Write("=");

            if (val.Contains(" "))
            {
                file.Write("\"");
                file.Write(val);
                file.Write("\"");
            }
            else
            {
                file.Write(val);
            }
        }

        file.Write(" ");
        file.WriteLine(this.options
        ["filenames"].Replace('\n', ' '));
    }
    catch (System.Exception excep)
    {
        MessageBox.Show(excep.Message, 
        "DeepEnds", MessageBoxButton.OK, MessageBoxImage.Error);
    }
}

Discussion

Lots of GUIs are just fluff with a high maintenance cost designed to reduce short term training costs. For the particular example given here, this is partially true, the reason why this example exists is that one of the optional outputs requires Visual Studio to read it. The advantage of the Command button is that it uses reflection to find the path to the location where Visual Studio has installed the extension.

The implementation described above minimizes the cost of testing compared with a static implementation - as extra keys are added, basic testing can be done by running the command line help. Obviously, the command line application could have been completely independent of the GUI, for example, written in a different language, and just run within a process.

The code from which the example was taken is available from GitHub.

History

  • 2016/10/26: First release
  • 2016/11/28: Re-direct the text output to the output pane of Visual Studio

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here