Turbo Vision 复活 – 适用于 C# 并带有 XAML
如何使用 XAML 快速创建具有伪图形的控制台应用程序
引言
创建 GUI 应用程序的 API 有很多。它们在开发中不断进步,变得越来越方便、简单、更好。但没有人关心老式好用的文本用户界面!尽管它是一种创建可在所有平台上运行的应用程序的非常简单的方式,包括通过 SSH 连接的终端模拟器中启动。恕我直言,这是一个疏忽。当我小时候上学时,我有一台 Pentium I 133MHz 电脑。我使用过许多 TUI 程序,它们的界面给我留下了深刻的印象。它们是 RAR 压缩程序、Norton Commander、FAR 管理器。但当我想编写类似的东西时,我对可用的工具感到失望。
我决定编写自己的库,以提供最简单、最有效的方式,使用现代技术实现简单的 TUI 应用程序。
技术概述
该库是用 C# 编写的,可以在 Windows、Linux 和 Mac OS X 上构建。在 Windows 中不需要额外的库,因为标准的 Win32 控制台 API 就足够了。在 Linux 和 Mac 中,使用了以下额外的库:libc、libtermkey 和 ncurses。
Linux 和 Mac OS X 中的原生依赖项
Libc
是标准库,包含在所有 Linux 和 Mac 发行版中。它用于轮询(POSIX 等效于 WaitForMultipleObjects
)标准输入 (STDIN) 和自管道。自管道是必要的,以便在需要异步在 UI 线程中执行某些代码时中断对输入事件的等待。
NCurses
用于在屏幕上渲染符号。实际上,并非必须使用此库,但我决定使用它以避免不同终端正确处理的麻烦。使用此库使我能够快速将代码从 Win32 移植到 Linux/Mac。将来,也许 ncurses 将从必需的依赖项中删除。
Libtermkey 是一个由 Paul Evans 编写的出色库。它允许处理键盘和鼠标输入,并且无需担心各种终端。它具有优雅的 C API。
主要对象
任何程序的中心对象都是 ConsoleApplication
。它是一个单例对象,可通过 ConsoleApplication.Instance
属性访问。当您调用 ConsoleApplication.Run(control)
时,它会启动事件循环,该循环仅在调用 ConsoleApplication.Stop()
方法后停止。
您可以将任何 Control
传递给 Run()
方法。但通常,如果您想创建基于窗口的程序,您应该在此处传递 WindowsHost
实例。WindowsHost
是一个管理子控件(窗口和单个主菜单)的控件。
传递给 Run()
方法的控件成为 根元素。控制台应用程序只有一个根元素,并且在调用 Stop()
之前无法更改。
EventManager
负责将路由事件传递给其订阅者。路由事件的工作方式类似于 WPF 的路由事件。它们可以是冒泡事件、隧道事件或直接事件。冒泡事件从源控件向上传播到控件树的根(根元素),隧道事件从根向下传播到源。直接事件不在控件树中传播。应用程序中只有一个事件管理器。
FocusManager
跟踪键盘焦点。它也是一个单例对象,类似于 Event Manager
,并提供用于操作键盘焦点的 API 访问。控件被允许调用 FocusManager.SetFocusScope()
方法或 FocusManager.SetFocus()
将键盘焦点传递给指定的控件。
布局系统
布局系统与 Windows Presentation Foundation 类似。但在这个库中,没有将 ContentControls 和 ItemsControls 分开。任何 Control
都可以有一个子控件或多个子控件。但测量和排列的算法与 WPF 几乎相同。如果您想编写自定义控件,您应该阅读 WPF 测量和排列子控件。与 WPF 在布局方面的差异:
- 没有 ContentControls 和 ItemsControls。
- 没有模板:没有必要这样做。它很复杂,并且控制台控件没有那么多像素来允许模板组合。
- 没有 LogicalTree 和 VisualTree 之间的区别(因为 #2)。
- 没有
InvalidateMeasure()
方法。Invalidate()
完全使控件无效。
渲染系统
渲染是在 Renderer
类中实现的。它在每个事件循环迭代中调用,并更新所有无效的控件。如果您为某个控件调用了 Invalidate()
,它将刷新其渲染缓冲区并无论如何都会将其刷新到屏幕。如果您在某个子控件上调用了 Invalidate()
,并且该控件已重新测量到相同大小,则父控件将不会受到失效过程的影响。如果重新测量的子控件的所需大小与之前的计算不同,则失效状态会传播到父控件。
处理无效控件后,更新的控件将其渲染缓冲区刷新到 PhysicalCanvas
实例。最后,PhysicalCancas
将其缓冲区刷新到终端屏幕。
XAML 和数据绑定
XAML 支持是使用自定义解析器实现的。做出此选择是为了避免标准 .NET XAML API 的麻烦,并避免对 .NET 类库的 WPF 部分的依赖。与 WPF 在 XAML 方面的差异是:
- 没有 附加属性 (可能会在以后添加)
- 没有生成代码:整个标记在运行时解析
- 没有
x:Name
属性,取而代之的是x:Id
- 不支持包含 (稍后会添加)
- 在值转换、添加到集合方面可能存在一些差异(因为这种 XAML 处理方法与 WPF 的代码生成方案不同)
- 标记扩展语法的一些差异——例如,单引号中不允许出现未转义的符号。
- 数据上下文对象不继承自父控件,它作为参数传递并对使用指定 XAML 配置的整个对象保持有效。
源代码
源代码可在我的 github 上找到:https://github.com/elw00d/consoleframework,当前状态已打包并附在文章中。
文档目前仅提供俄语版本,但稍后会进行翻译。
简单示例
让我向您展示一些使用此 API 的示例。请看 Commanding 示例。
以下标记创建了一个带有 CheckBox
和 Button
的 Window
。CheckBox
的 Checked
属性绑定到数据上下文的 ButtonEnabled
属性。按钮的 Command
引用了相同上下文的 MyCommand
属性。
<Window>
<Panel>
<CheckBox Caption="Enable button" Margin="1" HorizontalAlignment="Center"
Checked="{Binding Path=ButtonEnabled, Mode=TwoWay}"/>
<Button Command="{Binding MyCommand, Mode=OneTime}">Run command !</Button>
</Panel>
</Window>
DataContext
对于数据绑定生效是必要的。
/// <summary>
/// INotifyPropertyChanged is necessary because we are using TwoWay binding
/// to ButtonEnabled to pass default value true to CheckBox. If Source doesn't
/// implement INotifyPropertyChange, TwoWay binding will not work.
/// </summary>
private sealed class DataContext : INotifyPropertyChanged {
public DataContext() {
command = new RelayCommand(
parameter => MessageBox.Show("Information", "Command executed !", result => { }),
parameter => ButtonEnabled );
}
private bool buttonEnabled = true;
public bool ButtonEnabled {
get { return buttonEnabled; }
set {
if ( buttonEnabled != value ) {
buttonEnabled = value;
command.RaiseCanExecuteChanged( );
}
}
}
private readonly RelayCommand command;
public ICommand MyCommand {
get {
return command;
}
}
public event PropertyChangedEventHandler PropertyChanged;
}
当 ButtonEnabled
改变时,也会导致 command.CanExecute
改变。它会影响按钮的 Disabled
状态。
入口点代码也很简单:它只是从 XAML 加载 WindowsHost
和 Window
,然后启动主事件循环。
public static void Main(string[] args) {
DataContext dataContext = new DataContext();
WindowsHost windowsHost = (WindowsHost)ConsoleApplication.LoadFromXaml(
"Examples.Commands.windows-host.xml", dataContext);
Window mainWindow = (Window)ConsoleApplication.LoadFromXaml(
"Examples.Commands.main.xml", dataContext);
windowsHost.Show(mainWindow);
ConsoleApplication.Instance.Run(windowsHost);
}
WindowsHost
是这里的主要控件,它包含所有窗口和主菜单。主菜单在 windows-host.xml 文件中声明。
<WindowsHost>
<WindowsHost.MainMenu>
<Menu HorizontalAlignment="Center">
<Menu.Items>
<MenuItem Title="_Commands" Type="Submenu" Gesture="Alt+C">
<MenuItem Title="_Run command" TitleRight="Ctrl+R" Gesture="Ctrl+R"
Command="{Binding MyCommand, Mode=OneTime}"/>
</MenuItem>
</Menu.Items>
</Menu>
</WindowsHost.MainMenu>
</WindowsHost>
更复杂的例子
它是 cmdradio 项目的 TUI 包装器。我只是在原始 cmdradio 源代码中添加了大约 200 行代码 来创建这个程序。这是主窗口的 XAML 标记。
<Window Title="cmdradio" xmlns:x="http://consoleframework.org/xaml.xsd"
xmlns:cmdradio="clr-namespace:cmdradio;assembly=cmdradio">
<Window.Resources>
<cmdradio:StringToTextBlockVisibilityConverter x:Key="1" x:Id="converter"/>
</Window.Resources>
<Panel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
</Grid.RowDefinitions>
<GroupBox Title="Genres">
<Panel Orientation="Vertical">
<ComboBox ShownItemsCount="20"
MaxWidth="30"
SelectedItemIndex="{Binding Path=SelectedGenreIndex, Mode=OneWayToSource}"
Items="{Binding Path=Genres, Mode=OneWay}"/>
<Panel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,1,0,0">
<TextBlock Text="Volume"/>
<cmdradio:VolumeControl Percent="{Binding Path=Volume}"
Margin="1,0,0,0" Width="20" Height="1"/>
</Panel>
</Panel>
</GroupBox>
<GroupBox Title="Control" HorizontalAlignment="Right">
<Panel Margin="1">
<Button Name="buttonPlay" Caption="Play" HorizontalAlignment="Stretch"/>
<Button Name="buttonPause" Caption="Pause" HorizontalAlignment="Stretch"/>
<Button Name="buttonStop" Caption="Stop" HorizontalAlignment="Stretch"/>
<Button Name="buttonExit" Caption="Exit" HorizontalAlignment="Stretch"/>
</Panel>
</GroupBox>
</Grid>
<TextBlock Visibility="{Binding Path=Status, Mode=OneWay, Converter={Ref converter}}" Text="{Binding Path=Status, Mode=OneWay}"/>
<TextBlock Visibility="{Binding Path=Status2, Mode=OneWay, Converter={Ref converter}}" Text="{Binding Path=Status2, Mode=OneWay}"/>
<TextBlock Visibility="{Binding Path=Status3, Mode=OneWay, Converter={Ref converter}}" Text="{Binding Path=Status3, Mode=OneWay}"/>
</Panel>
</Window>
这个例子展示了如何创建基于网格的标记以及如何放置控件。标记与 WPF 的 XAML 非常相似。如你所见,对于这种复杂的布局来说,标记并不算太大(而且可以优化)。
参与项目
该库目前正处于积极开发中,因此非常感谢在编写附加控件和文档方面的帮助。以下控件目前仍缺失:选项卡面板、文本编辑器、上下文菜单、单选按钮、状态栏、停靠面板。如果您想帮助编写它们或有改进项目的好主意,请给我发送电子邮件至 elwood.su@gmail.com 或通过 github 联系。
TLDR(太长不看)
这是一个跨平台 API,允许使用基于 WPF 概念的现代技术开发 TUI 应用程序:XAML、数据绑定、路由事件、命令、WPF 兼容的布局系统。可在 Windows (x86, x86_64)、Linux (x86, x86_64) 和 Mac OS X 上运行。兼容绝大多数终端模拟器,包括 putty、xterm、gnome-terminal、konsole、yaquake、Terminal App (Mac OS)、iTerm2 (Mac OS)。如果您想尝试使用此工具包构建 TUI 应用程序,请参阅示例和文档。