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

    一.前言 Seata出现前,大部分公司使用的都是TCC或者MQ(RocketMq)等来解决分布式事务的问题,TCC代码编写复杂,每个业务均需要实现三个入口,侵入性强,RocketMQ保证的是最终一致性 ...

  2. Avalonia开发(一)环境搭建

    一.介绍 开源 GitHub:https://github.com/AvaloniaUI/Avalonia/ 多平台支持,包括Windows.mac OS.Linux.iOS.Android.Sams ...

  3. golang .(type)语法

    一直弄不懂 .(type) 是啥,在 liteide 中输出 (1+1).(type),提示: use of .(type) outside type switch 于是搜索到这个文章: 作者:翔云翔 ...

  4. 【Python】代理池针对ip拦截破解

    代理池是一种常见的反反爬虫技术,通过维护一组可用的代理服务器,来在被反爬虫限制的情况下,实现数据的爬取.但是,代理池本身也面临着被目标网站针对ip进行拦截的风险. 本文将详细介绍代理池针对ip拦截破解 ...

  5. 多租户基于Springboot+MybatisPlus实现使用一个数据库一个表 使用字段进行数据隔离

    多租户实现方式 多租户在数据存储上主要存在三种方案,分别是: 1. 独立数据库 即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高. 优点:为不同的租户提供独立的数据库,有助 ...

  6. 内网离线安装docker并配置使用nexus为docker私服

    背景 本文简单记录下最近在内网服务器离线安装docker及配置nexus作为docker私服,踩的一些坑.docker和k8s这块技术我跟得不是很紧,18年的时候用过一阵docker,后来发现它并不能 ...

  7. Django框架项目之课程主页——课程页页面、课程表分析、课程表数据、课程页面、课程接口、前台、后台

    文章目录 1-课程页页面 课程组件 2 课程主页之课程表分析 课程表分析 免费课案例 创建models:course/models.py 注册models:course/adminx.py 数据库迁移 ...

  8. HexConversion 二进制 八进制 十六进制 十进制

    public class HexConversion { // TODO Auto-generated method stub /** * TODO 进制转换. * * @param cc * htt ...

  9. MOOC慕课课表

    8. 教育法学,共11单元---课件全开放状态,可以1次全学完开课时间: 2020年08月17日 ~ 2020年12月16日进行至第1周,共18周学时安排: 3-5小时每周 9. 教师职业道德与教育政 ...

  10. 《最新出炉》系列初窥篇-Python+Playwright自动化测试-18-处理鼠标拖拽-上篇

    1.简介 本文主要介绍两个在测试过程中可能会用到的功能:在selenium中宏哥介绍了Actions类中的拖拽操作和Actions类中的划取字段操作.例如:需要在一堆log字符中随机划取一段文字,然后 ...