老周是一个不喜欢做界面的码农,所以很多时候能用控制台交互就用控制台交互,既方便又占资源少。有大伙伴可能会说,控制台全靠打字,不好交互。那不一定的,像一些选项类的交互,可以用键盘按键(如方向键),可比用鼠标快得多。当然了,要是要触控的话,是不太好用,只能做UI了。

关于控制台交互,大伙伴们也许见得最多的是进度条,就是输出一行但末尾不加 \n,而是用 \r 回到行首,然后输出新的内容,这样就做出进度条了。不过这种方法永远只能修改最后一行文本。

于是,有人想出了第二种方案——把要输出的文本存起来(用二维数组,啥的都行),每次更新输出时把屏幕内容清空重新输出。这就类似于窗口的刷新功能。缺点是文本多的时候会闪屏。

综合来说,局部覆盖是最优方案。就是我要修改某处的文本,我先把光标移到那里,覆盖掉这部分内容即可。这么一来,咱们得了解,在控制台程序中,光标是用行、列定位的。其移动的单位不是像素,是字符。比如 0 是第一行文本,1 是第二行文本……对于列也是这样。所以,(2, 4) 表示第三行的第五个字符处。这个方案是核心原理。

当然了,上述方案只是程序展示给用户看的,若配合用户的键盘输入,交互过程就完整了。

下面给大伙伴们做个演示,以便了解其原理。

internal class Program
{
static void Main(string[] args)
{
// 我们先输出三行
Console.WriteLine("====================");
Console.WriteLine("你好,小子");
Console.WriteLine("===================="); // 我们要改变的是第二行文本
// 所以top=1
int x = 10;
do
{
// 重新定位光标
Console.SetCursorPosition(0, 1);
Console.Write("离爆炸还剩 {0} 秒", x);
Thread.Sleep(1000);
}
while ((--x) >= 0); Console.SetCursorPosition(0, 1);
Console.Write("Boom!!");
Console.Read();
}
}

SetCursorPosition 方法的签名如下:

public static void SetCursorPosition(int left, int top);

left 参数是指光标距离控制台窗口左边沿的位移,top 参数指定的是光标距离窗口上边沿的位移。因此,left 表示的是列,top 表示的是行。都是从 0 开始的。

你得注意的是,在覆盖旧内容的时候,要用 Write 方法,不要调用 WriteLine 方法。你懂的,WriteLine 方法会在末尾产生换行符,那样会破坏原有文本的布局的,覆写后会出现N多空白行。

咱们看看效果。

这时候会发现一个问题:输出“Boom!!”后,后面还有上一次的内容未完全清除,那是因为,新的内容文本比较短,没有完全覆写前一次的内容。咱们可以把字符串填充一下。

Console.Write("Boom!!".PadRight(Console.BufferWidth, ' '));

BufferWidth 是缓冲区宽度,即一整行文本的宽度。Buffer 指的是窗口中输出文本的一整块区域,它的面积会大于/等于窗口大小。不过,咱们好像也没必要填充那么多空格,比竟文本不长,要不,咱们就填充一部分空格好了。

Console.Write("Boom!!".PadRight(30, ' '));

30 是总长度,即字符加上填充后总长度为 30。好了,这下子就完美了。

存在的问题:直接运行控制台应用程序是一切正常的,但如果先启动 CMD,再运行程序就不行了。原因未知。

咱们也不总是让用户输入命令来交互的,也可以列一组选项,让用户去选一个。下面咱们举一例:运行后输出五个选项,用户可以按上、下箭头键来选一项,按 ESC/回车 可以退出循环。

static void Main(string[] args)
{
// 下面这行是隐藏光标,这样好看一些
Console.CursorVisible = false;
const string Indicator = "* "; // 前导符
int indicatWidth = Indicator.Length;// 前导符长度 // 先输出选项
string[] options = [
"雪花",
"梨花",
"豆腐花",
"小花",
"眼花"
];
foreach(string s in options)
{
Console.WriteLine(s.PadLeft(indicatWidth + s.Length));
} // 表示当前所选
int currentSel = -1;
// 表示前一个选项
int prevSel = -1; ConsoleKeyInfo key;
while(true)
{
key = Console.ReadKey(true);
// ESC/Enter 退出
if (key.Key == ConsoleKey.Escape || key.Key == ConsoleKey.Enter)
{
// 光标移出选项列表所在的行
Console.SetCursorPosition(0, options.Length+1);
break;
}
switch (key.Key)
{
case ConsoleKey.UpArrow: // 向上
prevSel = currentSel; // 保存前一个被选项索引
currentSel--;
break;
case ConsoleKey.DownArrow:
prevSel = currentSel;
currentSel++;
break;
default:
// 啥也不做
break;
}
// 先清除前一个选项的标记
if(prevSel > -1 && prevSel < options.Length)
{
Console.SetCursorPosition(0, prevSel);
Console.Write("".PadLeft(indicatWidth, ' '));
}
// 再看看当前项有没有超出范围
if (currentSel < 0) currentSel = 0;
if (currentSel > options.Length - 1) currentSel = options.Length - 1;
// 设置当前选择项的标记
Console.SetCursorPosition(0, currentSel);
Console.Write(Indicator);
}
if(currentSel != -1)
{
var selItem = options[currentSel];
Console.WriteLine($"你选的是:{selItem}");
}
}

首先,CursorVisible 属性设置为 false,隐藏光标,这样用户在操作过程看不见光标闪动,会友好一些。毕竟我们这里不需要用户输入内容。

选项内容是通过字符串数组来定义的,先在屏幕上输出,然后在 while 循环中分析用户按的是不是上、下方向键。向上就让索引 -1,向下就让索引 +1。为什么要定义一个 prevSel 变量呢?因为这是单选项,同一时刻只能选一个,被选中的项前面会显示“* ”。当选中的项切换后,前一个被选的项需要把“* ”符号清除掉,然后再设置新选中的项前面的“* ”。所以,咱们需要一个变量来暂时记录上一个被选中的索引。

如果你的程序逻辑复杂,这些功能可以封装一下,比如用某结构体记录选择状态,或者干脆加上事件处理,当按上、下键后调用相关的委托触发事件。这里我为了让大伙伴们看得舒服一些,就不封装那么复杂了。

运作过程是这样的:

1、初始时,一个没选上;

2、按【向下】键,此时当前被选项变成0(即第一项),上一个被选项仍然是 -1;

3、前一个被选项是-1,无需清除前导字符;

4、设置第0行(0就是刚被选中的)的前导符,即在行首覆写上“* ”;

5、继续按【向下】键,此时被选项为 1,上一个被选项为 0;

6、清除上一个被选项0的前导符,设置当前项1的前导符;

7、如果按【向上】键,当前选中项变回0,上一个被选项是1;

8、清除1处的前导符,设置0处的前导符。

其他选项依此类推。

来,看看效果。

怎么样,还行吧。可是,你又想了:要是在被选中时改变一下背景色,岂不美哉。好,改一下代码。

……
// 先清除前一个选项的标记
if(prevSel > -1 && prevSel < options.Length)
{
Console.SetCursorPosition(0, prevSel);
// 把背景改回默认
Console.ResetColor();
Console.Write("".PadLeft(indicatWidth, ' ') + options[prevSel]);
}
// 再看看当前项有没有超出范围
if (currentSel < 0) currentSel = 0;
if (currentSel > options.Length - 1) currentSel = options.Length - 1;
// 设置当前选择项的标记
// 这一次不仅要写前导符,还要重新输出文本
Console.BackgroundColor = ConsoleColor.Blue; // 背景蓝色
Console.SetCursorPosition(0, currentSel);
// 文本要重新输出
Console.Write(Indicator + options[currentSel]);
……

ResetColor 方法是重置颜色为默认值,BackgroundColor 属性设置文本背景色。颜色一旦修改,会应用到后面所输出的文本。所以当你要输出不同样式的文本前,要先改颜色。

效果很不错的。

咱们扩展一下思路,还可以实现能动态更新的表格。请看以下示例:

static void Main(string[] args)
{
// 隐藏光标
Console.CursorVisible = false;
// 控制台窗口标题
Console.Title = "万人迷赛事直通车";
// 生成随机数对象,稍后用它随机生成时速
Random rand = new(DateTime.Now.Nanosecond);
// 第0行:标题
Console.WriteLine("2023非正常人类摩托车大赛");
// 第1行:分隔线
Console.WriteLine("--------------------------------------------");
// 第2行:表头
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("{0,-4}", "编号");
Console.Write("{0,-8}", "选手");
Console.Write("{0,-5}", "颜色");
Console.Write("{0,-8}\n", "实时速度(Km)");
Console.ResetColor(); // 重置颜色 // 数据
string[][] data = [
["1", "张天师", "白", "78"],
["2", "王光水", "蓝", "81"],
["3", "戴胃王", "红", "80"],
["4", "马真帅", "黄", "77"],
["5", "钟小瓶", "黑", "83"],
["6", "江三鳖", "紫", "78"]
];
// 输出数据
foreach (var dt in data)
{
Console.Write("{0,-6}{1,-7}{2,-6}{3,-5}\n", dt[0], dt[1], dt[2], dt[3]);
} // 数据列表开始行
int startLine = 3;
// 数据列表结束行
int endLine = startLine + data.Length;
// 覆写开始列
int startCol = 23;
// 循环更新
while(true)
{
for(int i = startLine; i < endLine; i++)
{
// 生成随机数
int num = rand.Next(60, 100);
// 移动光标
Console.SetCursorPosition(startCol, i);
// 覆盖内容
Console.Write($"{num,-5}");
// 暂停一下
Thread.Sleep(300);
}
}
}

这个例子在 while 循环内生成随机数,然后逐行更新最后一个字段的值。

运行效果如下:

下面咱们来做来好玩的进度条。

static void Main(string[] args)
{
Console.CursorVisible = false;
// 进度条模板
string strTemplate = "[ {0,5:P0} ]";
Console.WriteLine(string.Format(strTemplate, 0.0d)); for (int i = 0; i <= 100; i++)
{
// 计算比例
double pc = (double)i / 100;
// 产生进度文件
string pstr = string.Format(strTemplate, pc);
// 两边的中括号不用覆盖
var subContent = pstr[1..^1];
// 总字符数
int totalChars = subContent.Length;
// 有多少个字符要高亮显示
int highlightChars = (int)(pc * totalChars); // 定位光标
Console.SetCursorPosition(1, 0);
// 改变颜色
Console.ForegroundColor = ConsoleColor.Black;
Console.BackgroundColor = ConsoleColor.DarkYellow;
// 先写前半段字符串
Console.Write(subContent.Substring(0, highlightChars));
// 重置颜色
Console.ResetColor();
// 再写后半段字符串
Console.Write(subContent.Substring(highlightChars));
// 暂停一下
Thread.Sleep(100);
}
// 重置颜色
Console.ResetColor();
Console.WriteLine();
Console.Read();
}

效果如下:

说说原理:

1、进度字符串的格式:[             100%              ],百分比显示部分固定为五个字符(格式控制符 {0,5:P0});

2、头尾的中括号是不用改变的,但[、]之间的内容需要每次刷新;

3、根据百分比算出,代表进度的字符个数。方法是 HL = 字符串总长(除去两边的中括号)× xxx%;

4、将要覆盖的字符串内容分割为两段输出。

a、第一段字符串输出前把背景色改为深黄色,前景色改为黑色。然后输出从 0 索引处起,输出 HL 个字符;

b、第二段字符串输出前重置颜色,接着从索引 HL 起输出直到末尾。

随着百分比的增长,第一段字符的长度越来越长——即背景为DarkYellow 的字符所占比例更多。

现在,获取控制台窗口句柄来绘图的方式已经不能用了。不过,咱们通过字符也是可以拼接图形的。咱们看例子。

#pragma warning disable CA1416
static void Main(string[] args)
{
Console.CursorVisible = false; // 隐藏光标
Console.SetWindowSize(100, 100);
Bitmap bmp = new Bitmap(32, 32);
using(Graphics g = Graphics.FromImage(bmp))
{
g.Clear(Color.White);
// 画笔
Pen myPen = new(Color.Black, 1.0f);
g.DrawEllipse(myPen, new Rectangle(0, 0, bmp.Width-1, bmp.Height-1));
}
// 逐像素访问位图
// 如果遇到黑色就填字符,白色就是空格
for(int h = 0; h < bmp.Height; h++)
{
// 定位光标
Console.SetCursorPosition(0, h);
for (int w = 0; w < bmp.Width; w++)
{
Color c = bmp.GetPixel(w, h);
// 黑色
if(c.ToArgb() == Color.Black.ToArgb())
{
Console.Write("**");
}
// 白色
else
{
Console.Write(" ");
}
}
} }
#pragma warning restore CA1416

控制台应用程序项目要添加以下 Nuget 包:

<ItemGroup>
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
</ItemGroup>

这是为了使用 Drawing 相关的类。我说说上面示例的原理:

1、先创建内存在的位图对象(Bitmap类);

2、用 Graphics 对象,以黑色钢笔画一个圆。注意,笔是黑色的,后面有用;

3、逐像素获取位图的颜色,映射到控制台窗口的行、列中。如果像素是黑色,就输出“**”,否则输出“  ”(两个空格)。

为什么要用两个字符呢?用一个字符它的宽度太窄,图像会变形,只好用两个字符了。汉字就不需要,一个字符即可。

咱们看看效果。

生成位图时,尺寸不要太大,不然很占屏幕。毕竟控制台是以字符来计量的,不是像素。

【.NET】控制台应用程序的各种交互玩法的更多相关文章

  1. Web应用程序或者WinForm程序 调用 控制台应用程序及参数传递

    有时候在项目中,会调用一个控制台应用程序去处理一些工作.那在我们的程序中要怎么样做才能调用一个控制台应用程序并将参数传递过去,控制台程序执行完后,我们的程序又怎样获取返回值?代码如下:调用代码:    ...

  2. 用C#实现模拟双色球中奖程序 控制台应用程序

    前言 这是我在大一第一学期C#的课程设计,要求编写一个模拟双色球彩票的控制台应用程序,用以实现简单的模拟选购彩票. 一.双色球购号号码生成: 1.系统购号:通过"随机数"产生双色球 ...

  3. asp.net mvc引用控制台应用程序exe

    起因:有一个控制台应用程序和一个web程序,web程序想使用exe程序的方法,这个时候就需要引用exe程序. 报错:使用web程序,引用exe程序 ,vs调试没有问题,但是部署到iis就报错,如下: ...

  4. Win32程序和控制台应用程序的项目互转设置

    一般情况下,如果是windows程序,那么WinMain是入口函数,在VS2010中新建项目为"win32项目" 如果是dos控制台程序,那么main是入口函数,在VS2010中新 ...

  5. 【C#】1.2 控制台应用程序学习要点

    分类:C#.VS2015 创建日期:2016-06-14 教材:十二五国家级规划教材<C#程序设计及应用教程>(第3版) 一.要点概述 <C#程序设计及应用教程>(第3版)的第 ...

  6. 【C++】第1章 在VS2015中用C++编写控制台应用程序

    分类:C++.VS2015 创建日期:2016-06-12 一.简介 看到不少人至今还在用VC 6.0开发工具学习C++,其实VC 6.0开发工具早就被淘汰了.这里仅介绍学习C++时推荐使用的两种开发 ...

  7. c#取得控制台应用程序根目录

    1.取得控制台应用程序的根目录方法 方法1.Environment.CurrentDirectory 取得或设置当前工作目录的完整限定路径方法2.AppDomain.CurrentDomain.Bas ...

  8. vc2010 win32 控制台应用程序中文乱码

    vc2010 win32 控制台应用程序中文乱码 在 vc2010 上用 win32 控制台程序写些测试代码调用 windows api ,处理错误信息时,发现用 wprintf 输出的错误信息出现了 ...

  9. c# 隐藏 控制台应用程序

    using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.D ...

  10. 如何编写一个编译c#控制台应用程序的批处理程序

    如何编写一个编译c#控制台应用程序的批处理程序 2011-03-22 18:14 dc毒蘑菇 | 浏览 579 次 最近在网上看了一个教程,是学C#的,但是我的机子上装不上vs,所以想写一个批处理来编 ...

随机推荐

  1. MIT 6.828 Lab实验记录 —— lab1 Booting PC

    实验参考信息 MIT 6.828 lab1 讲义地址 MIT 6.828 课程 Schedule MIT 6.828 lab 环境搭建参考 MIT 6.828 lab 工具guide Brennan' ...

  2. wget 命令的使用:HTTP文件下载、FTP文件下载--九五小庞

    1. wget 命令简介与安装wget是用于在命令行终端下载网络文件的开源免费的命令工具,支持 HTTP/HTTPS.FTP/FTPS 协议的下载.wget 与 curl 相似,curl 可以理解为是 ...

  3. CodeForces 1311E Construct the Binary Tree

    题意 给定\(n\)和\(d\),构造一颗\(n\)个节点的二叉树(以\(1\)为根),所有节点到\(1\)的距离和为\(d\),不行输出\(NO\),否则输出\(YES\)和\(2\)-\(n\)的 ...

  4. RK3568开发笔记(六):开发板烧写ubuntu固件(支持mipi屏镜像+支持hdmi屏镜像)

    前言   编译了uboot,kernel,buildroot后,可以单独输入固件,也可以整体打包成rootfs进行一次性输入,rootfs直接更新升级这个方式目前也是常用的.   烧写器软件:RKDe ...

  5. linux tcpdump 使用小结(二)

    转载请注明出处: TCPDump是一个功能强大的网络抓包工具,它能够在命令行界面捕获.分析和解析网络数据包.下面是TCPDump命令的使用总结,包括使用语法.常用参数说明等: 使用语法:tcpdump ...

  6. 【krpano】多分类缩略图及多分类地图案例

    该案例提供了场景多分类缩略图展示以及多地图展示,效果如下截图:                 下载地址:http://pan.baidu.com/s/1hsA5ta8 感谢群内小伙伴H·T·T的分享 ...

  7. 文心一言 VS 讯飞星火 VS chatgpt (106)-- 算法导论10.1 4题

    四.用go语言,重写 ENQUEUE 和 DEQUEUE 的代码,使之能处理队列的下溢和上溢. 文心一言: 在 Go 语言中,我们可以使用切片来实现队列.下面是如何实现 ENQUEUE 和 DEQUE ...

  8. PostgreSQL学习笔记-7.基础知识:子查询、自增、PRIVILEGES 权限

    子查询 子查询或称为内部查询.嵌套查询,指的是在 PostgreSQL 查询中的 WHERE 子句中嵌入查询语句.一个 SELECT 语句的查询结果能够作为另一个语句的输入值.子查询可以与 SELEC ...

  9. PPT图片处理教程5大抠图方法:堪比PS!

    PPT图片处理教程5大抠图方法:堪比PS! .如何在制作PPT的过程中提取图片.比如,当你准备给妹子做一份PPT的时候,妹子想要把N多图片放在里面,但又不想做的太LOW,这要怎么解(gai)咯?如果你 ...

  10. 常见的企业Wiki

    企业Wiki(Enterprise Wiki)指适用于企业或组织内部使用的Wiki.与非企业Wiki(如著名的MediaWiki)最根本的不同点在于,企业Wiki是为企业量身定做的Wiki.通过鼓励. ...