65.9K
CodeProject 正在变化。 阅读更多。
Home

从控制台应用程序生成 WPF 前端

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2016年10月26日

CPOL

4分钟阅读

viewsIcon

23989

如何构建一个命令行应用程序,使其在修改时,GUI 能够自动更改。

引言

本文讨论了在 C# 命令行应用程序上实现一个最小化的 WPF 前端。具体的例子最初是一个 Visual Studio 扩展,测试这样的扩展需要构建一个 VSIX 文件,然后启动一个特殊的 Visual Studio 实例 - 这两者都非常耗时。考虑到 GUI 仅限于选择文件,因此命令行应用程序对于测试和一般使用都是合理的。应用程序的结构如下:

Assembly structure

在此,Console .NET 程序集被展开以显示其构成类。所有被 Console 链接的程序集都通过其 View 类进行链接,GUI 程序集可以访问 Console 可执行文件中的所有类,除了那个命名不太好听的 Program 类,它提供了 main() 函数。

命令行

使用没有任何参数的命令行程序运行时,会显示帮助信息。帮助信息是动态构建的,用于构建它的例程的前几行如下:

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");

Splitter 类用一个缓冲区和一个行长度进行初始化,它有一个方法,该方法接受一个缩进和一个要写入的行。Options 类由一个枚举器和几个 static 方法组成,这些方法返回键值映射,其中键是表示参数的 stringOrdered static 方法返回一个键的数组,其中最后一个键恰好是非可选的。键数组被迭代两次;第一次用于构造命令行格式,第二次用于详细说明参数(它们的默认值和它们的具体帮助)。

当在命令行中指定参数时,它们会覆盖默认值,然后传递给 View 类进行处理。

随着新选项添加到程序中,预计只需要修改 Options 类。这些额外的键将自动被程序的命令行和 GUI 版本所处理。

图形用户界面

对话框的 XAML 代码简洁地表示为:

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

即,一个 1 行 3 列的网格,其中前两列包含按钮。这会在屏幕上显示如下内容:

The GUI

额外的行是从有序键构建的,键本身显示为第一列 TextBlock 实例上的标签。第二列允许在 TextBox 实例中指定与键关联的值。最后一列包含一个按钮,如果从 Options 类上的另一个 static 方法返回一个映射(从键到一个枚举器,指定是保存文件、打开文件还是选择目录)。最后,行上的每个项目都有一个工具提示,该工具提示是通过在没有缩进且长度为 80 的情况下,运行 Help() 映射中的相应项并通过 Splitter 类生成的。

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);
        }
    }
}

当按下浏览按钮时,随后的事件由一个函数处理,该函数首先查询调用者的名称,而名称恰好是键。然后使用该名称来访问说明要启动的对话框类型的映射 - 文件打开、文件保存或选择目录。对于文件,Options 类上还有一个附加的映射方法,它返回文件过滤器。当用户设置好路径后,相应的 TextBox 的值会相应地更改。

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;
        }
    }
}

如果 TextBox 的值发生更改,随后的事件会再次查询键值。然后使用该键值来覆盖在用默认值初始化的映射中的值。

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

当第一行的按钮被按下时,它们的后续输出需要被显示。这通过写入 Visual Studio 本身内的输出窗格来处理。

// 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");
    }
}

对于 Execute 按钮,输出窗格包含运行命令行应用程序会产生的输出。当按下 Command 按钮时,输出窗格包含运行命令行应用程序所需的文本;省略了包含默认值的可选参数。

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);
    }
}

讨论

许多 GUI 只是华而不实,维护成本很高,旨在降低短期培训成本。对于此处给出的特定示例,这在某种程度上是真的,这个示例存在的原因是其中一个可选输出需要 Visual Studio 来读取它。Command 按钮的优点在于它使用反射来查找 Visual Studio 安装扩展的位置路径。

与静态实现相比,上述实现最大限度地降低了测试成本 - 随着额外键的添加,可以通过运行命令行帮助来完成基本测试。显然,命令行应用程序可以完全独立于 GUI,例如,用另一种语言编写,并且仅在一个进程内运行。

可以从 GitHub 获取示例代码的来源。

历史

  • 2016/10/26:首次发布
  • 2016/11/28:将文本输出重定向到 Visual Studio 的输出窗格
© . All rights reserved.