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





5.00/5 (6投票s)
如何构建一个命令行应用程序,使其在修改时,GUI 能够自动更改。
引言
本文讨论了在 C# 命令行应用程序上实现一个最小化的 WPF 前端。具体的例子最初是一个 Visual Studio 扩展,测试这样的扩展需要构建一个 VSIX 文件,然后启动一个特殊的 Visual Studio 实例 - 这两者都非常耗时。考虑到 GUI 仅限于选择文件,因此命令行应用程序对于测试和一般使用都是合理的。应用程序的结构如下:
在此,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
方法组成,这些方法返回键值映射,其中键是表示参数的 string
。Ordered
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 列的网格,其中前两列包含按钮。这会在屏幕上显示如下内容:
额外的行是从有序键构建的,键本身显示为第一列 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 的输出窗格