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

控制台应用程序的窗体

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (42投票s)

2006年8月7日

CPOL

12分钟阅读

viewsIcon

119579

downloadIcon

3049

一个用于为控制台应用程序采用可重用、声明性界面的框架。

Sample Image - FFCA01.png

引言

厌倦了 GDI+?觉得理解托管 DirectX 需要物理学学位?XAML 的前景让你感到恶心吗?那么,让我们回到一个不需要担心将代码编组到 UI 线程等问题的简单时代吧。

原始表示层

尽管现代高分辨率 2D 和 3D 界面无处不在,但控制台应用程序仍然有其一席之地,并且在许多不同的应用程序环境中仍然可见。大型机界面、零售 POS 系统和远程监控应用程序都是不需要最前沿 GUI 界面的可执行文件的示例。节省下来的处理独特界面代码的时间可以用于调试或添加新功能。

由于 .NET 框架版本 1.1 通常被认为是 Windows 和 Web 应用程序的平台,因此对控制台的支持非常有限。除了 `Console.WriteLine` 和 `Console.ReadLine` 的非常有限的功能外,纯托管代码与控制台交互的方式很少。许多库通过包装 Win32 API 来实现前景和背景颜色更改以及光标定位,但它们不一致且偶尔会显得笨拙。

即使随着框架版本 2.0 的发布,它通过托管代码支持颜色、窗口大小调整和光标定位,但在控制台上编写绘制界面的代码仍然相当困难且重复。

这不是另一个关于如何在控制台应用程序中实现颜色的文章,而是关于更高层次的抽象。我将向您展示如何补充原生的 .NET 控制台功能,将控制台界面声明性地组织成控制台“窗体”,这些窗体可以从应用程序代码中外部化,并在运行时以编程方式引用,就像 WinForms 和 WebForms 一样,从而使您能够快速构建可重用的界面。

对象模型

我将简要概述库中的主要对象,然后详细介绍它们如何交互。

除了辅助类(如扩展的 `EventArgs` 类)之外,还有五个主要类。

根对象是 `ConsoleForm`,它包含 `Height` 和 `Width` 属性,`Name`(用作控制台窗口的标题),以及窗体管理的 `Line`、`Label` 和 `Textbox` 对象的集合。`ConsoleForm` 对象是定义 UI 元素的画布。

`Line` 对象可以是 `LineOrientation.Horizontal` 或 `LineOrientation.Vertical`,具有定义其在控制台上开始绘制位置的 `Location` 属性,定义线将向右或向下(取决于方向)绘制多远的 `Length` 属性,以及指定要用于绘制线的 `System.ConsoleColor` 的 `Colour` 属性。

`Label` 对象(从 UI 角度来看)是只读的,并具有 `Text`、`Location` 和 `Length` 属性。它们还具有 `Background` 和 `Foreground` 颜色属性。

`Textbox` 对象是读/写的,可以按 Tab 键切换,并接受按键输入。它们同样具有 `Text`、`Location` 和 `Length` 属性,但还包括一个 `PasswordChar` 属性,用于指定掩码字符以实现密码输入字段。与 `Label` 对象一样,它们具有 `Background` 和 `Foreground` 颜色属性。

`Point` 对象只是一个 X 和 Y 坐标的容器,因此控制台 UI 对象可以具有 `Location` 属性。

实际应用

`ConsoleForm` 对象是按编程方式创建的,或者从文件中反序列化。此库中的所有对象都实现了 `IXmlSerializable`,并在通过框架的 `XmlSerializer` 对象处理时持久化或反持久化自身。创建后,当调用 `Render()` 方法时,`ConsoleForm` 定义的数据将绘制到控制台窗口上。默认情况下,`Render()` 会在绘制 UI 元素之前清除屏幕,但您可以通过将 false 作为参数传递给方法调用来覆盖此行为。

调用 `Render()` 时,`ConsoleForm` 会调整控制台窗口和窗口缓冲区的大小(以避免出现滚动条),使其达到指定的 `Height` 和 `Width`。它会清除屏幕(如果请求),然后开始绘制 UI 元素。

通过将光标定位在由 `Location` 属性定义的屏幕点上,将控制台的 `Background` 颜色属性设置为线的 `Foreground` 颜色,然后绘制指定数量(`Length`)的空格向下或向右来绘制 `Line` 对象。

`Textbox` 和 `Label` 对象绘制方式相同,并且由于它们都继承自 `StdConsoleObject` 类,因此共享许多功能。光标会移动到 `Location` 属性描述的屏幕坐标;然后创建一个字符串,该字符串由 `Text` 属性生成,并根据需要用空格填充或截断以满足字段的 `Length` 属性。然后,它会以 `StdConsoleObject` 的 `Foreground` 和 `Background` 颜色使用 `Console.Write()` 方法进行写入。

渲染完成后,`ConsoleForm` 对象会将光标移动到 `Textboxes` 集合中的第一个 `Textbox`,然后等待按键。一旦收到按键,`ConsoleForm` 对象就会决定如何处理它。有几种可能的操作:

  • 如果字符是不可打印的(如光标键、功能键或控制键),`ConsoleForm` 会忽略它。
  • 如果字符是 [Enter],`ConsoleForm` 会引发 `FormComplete` 事件,以便应用程序可以决定下一步的操作。
  • 如果字符是 [Esc],`ConsoleForm` 会引发 `FormCancelled` 事件,以便应用程序可以决定下一步的操作。
  • 如果字符是 [Tab],`ConsoleForm` 会将光标移动到 `Textboxes` 集合中的下一个 `Textbox`。如果光标位于集合中的最后一个 `Textbox`,光标将移回到第一个 `Textbox`。如果按 [Tab] 时按住 [Shift],用户可以向后遍历 `Textbox` 集合。
  • 如果字符是 [Backspace],则从当前 `Textbox` 的末尾删除一个字符(如果可用),并将光标后退一个空格。在被退格的字符位置绘制一个背景颜色为 `Textbox` 背景颜色的空格。
  • 如果按下任何其他字符,并且 `Textbox` 的 `Length` 还有剩余空间,则会绘制该字符。

只有 [Enter] 和 [Esc] 会导致按键循环退出。它们将分别调用与 `FormComplete` 或 `FormCancelled` 绑定的事件,并将窗体的状态作为参数传递。稍后将详细介绍。

由于按键提示是一个阻塞调用 `Console.Read()`,我创建了一个新线程来等待按键。您的应用程序在后台进行的任何其他操作将保持良好响应。

声明性示例

随附的演示项目包含几个示例控制台窗体。下面的文档描述了 `LogReader` 示例应用程序的登录对话框屏幕。

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

`ConsoleForm` 节点下的第一个节点包含 `Line` 对象定义,第二个节点包含 `Label` 对象定义(说明和 `Textbox` 标识符),第三个节点包含 `Textbox` 对象定义(包括一个密码输入框)。对于 `Label` 对象,`Length` 属性是可选的,如果未显式提供,则会根据提供的 `Text` 属性的长度推断。上面的窗体定义渲染了以下窗体:

Login Screen

图 2:登录屏幕

在运行时实际绘制此窗体并等待其操作的代码如下:

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

此代码创建一个新的窗体对象,从运行的可执行文件下名为“Forms”的文件夹中的 *Login.xml* 文件反序列化窗体定义,将 `FormComplete` 和 `FormCancelled` 事件分别绑定到 `login_Complete` 和 `login_Cancelled` 方法,绑定 `KeyPressed` 事件,并显示窗体。然后,用户可以自由地在窗体中按 [Tab] 键并输入数据,直到他们按 [Enter] 键让库调用 `login_Complete` 方法,或者直到他们按 [Esc] 键让库调用 `login_Cancelled` 方法。

`onFormCancelled` 委托定义的方法签名如下:

private static void login_Cancelled(ConsoleForm sender,
                                    EventArgs e) {
   System.Environment.Exit(0);
}

在这里,按下登录窗体上的 [Esc] 键将导致应用程序退出。

`onFormComplete` 委托定义的方法签名如下:

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.

   }
}

此方法查看两个 `Textbox` 对象的内容,并执行一个简单的测试来验证用户。显然,您不会硬编码凭据,但我希望在这里专注于基本功能。如果 `txtLoginID` `Textbox` 的 `Text` 属性是“sean”,并且 `txtPassword` `Textbox` 的 `Text` 属性是“murphy”,则从磁盘反序列化主菜单窗体,绑定 `FormComplete` 事件(不绑定 `FormCancelled` 事件),并进行渲染。

如果凭据不匹配,则更新 `lblError` `Label` 以显示错误来源,并清除两个 `Textbox` 对象。`FormCompleteEventArgs` 参数的 `Cancel` 属性被设置为 true,以便在事件返回后库会取消销毁控制台窗体。如果未设置 `Cancel` 属性(如凭据匹配时),或显式设置为 false,则在事件返回时窗体将被销毁。按键循环线程将被终止,并且任何附加的事件都将被置空,以准备另一个 `ConsoleForm`(在上面的示例中是 *Menu.xml*)取而代之。

每个 `ConsoleForm` 都会跟踪它是否已被渲染。当您修改 `Label` 和 `Textbox` 对象的内容时,您可能是在修改一个已显示的窗体,或者您可能只是在准备将其显示到屏幕上的新 `ConsoleForm` 对象。如果窗体已被渲染,则对 `Label` 和 `Textbox` 对象 `Text` 属性的更改会立即反映在屏幕上,如上面的示例所示。如果窗体尚未渲染,则对 `Text` 属性的更改不会直接影响界面,并且只有在调用 `Render()` 后才会显示。

要检查的最后一个事件是由 `onKeyPress` 委托处理的。在登录示例中,它实现如下:

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

如果此事件已绑定,则事件处理程序将首先有机会检查用户按下的键,并决定是取消按键还是执行其他操作。在我们的示例中,我们使用任何按键来清除显示的错误(如果存在)。如果您对特定按键感兴趣,可以检查 `KeyPressEventArgs` 参数的 `Char` 属性,如果希望窗体引擎忽略按键,则将同一参数的 `Cancel` 属性设置为 true。`Cancel` 将阻止处理任何按键,包括 [Enter] 和 [Esc],否则这些按键会使应用程序从该窗体过渡。

编程示例

但是,您不限于外部定义的窗体。除了从磁盘反序列化控制台窗体外,您还可以通过代码构建它们。以下示例构建了示例应用程序的主菜单:

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

示例应用程序还展示了如何显示附加到计时器的窗体。我之前说过,退出窗体只有两种方式:“`FormComplete`”和“`FormCancelled`”,但您也可以根据其他应用程序事件在外部终止窗体。您只需要确保您拥有窗体的句柄,以便可以 `Dispose()` 它并终止按键线程,否则您显示的下一个窗体可能无法接收它预期的按键。

注释

替代方案

我知道还有一些其他机制可以类似地使用,例如 LynxNCurses。我选择编写自己的库是因为我想要一个精简的声明性定义语法和一个非常简单的对象模型。Lynx 和 NCurses 都功能强大,通常对于我想要快速创建的由标签、线条、文本框组成并响应单个按键的漂亮小界面来说是过度的。

关于可取消事件的讲道

`FormComplete` 和 `KeyPressed` 事件的委托都包含派生自 `System.EventArgs` 的类,其中包含一个名为 `Cancel` 的布尔属性,该属性允许事件处理程序中的代码将消息传回给最初调用委托的库代码,以告知它不应执行某些操作。这类似于 Windows Forms 的 `FormClosing` 事件处理程序,它允许您取消关闭操作。

通常,事件可以订阅任意数量的订阅者,但在可取消事件的情况下,我认为这没有意义。如果有多个 `FormClosing` 侦听器,其中一些将 `Cancel` 设置为 true,而另一些则将其设置为 false,那么只有最后一个人的投票才算数。事件代码无法“知道”它是否是事件链中的最后一个,以及它对 `Cancel` 属性状态的意见是否会被采纳。

因此,我只允许我的包含可取消属性的事件有一个订阅者。可以通过以下方式强制执行此操作:

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

声明一个委托变量,并在事件声明中包含显式的 `add{}` 代码。如果没有侦听器,它允许客户端代码添加一个。但如果委托不为 null,则表示已绑定事件,因此会引发 `InvalidOperationException` 来责备编码员。它阻止客户端这样做:

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

它会编译,但在命中第二个赋值时会生成运行时异常。

如果您包含 `add{}` 处理程序,编译器会强制您包含显式的 `remove{}` 处理程序,这允许我执行我的一项宠物协议。我讨厌框架允许您退订您并未订阅的事件。即使代码执行时没有抱怨,如果我退订了从未绑定过的东西,我希望了解它,因为它可能表明判断失误。上面的 `remove{}` 块中的代码只允许您退订已订阅的事件。如果您尝试退订任何其他事件,将会发生运行时错误。它阻止了这个:

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

Login Screen

图 3:未来派启动屏幕

结论

Vista 所需的计算能力坦白说令人尴尬,并且大部分将未被使用,除了驱动界面的硬件。98% 的应用程序不应该需要双核 CPU 和 600 美元的显卡。它们几乎肯定不需要剪切和旋转的 组合框

鉴于当前的计算趋势是将客户端“瘦身”到 Web 服务和 AJAX,我希望贡献一些代码来帮助简化运行时界面创建和管理。没有比控制台更简单的了,我希望我已经使其更加简单。

现在,去制作一些好看的控制台应用程序,向 Redmond(微软)展示界面不需要强大的硬件也能具有功能性和吸引力。

分享和享受。

历史

  • 2006 年 8 月 7 日 - 初始修订。
  • 2006 年 8 月 11 日 - 修复了 `Render()` 方法中的一个错误,该错误在尝试重新启动按键循环时(如果正在重用窗体)导致错误。
© . All rights reserved.