• 【.NET】控制台应用程序的各种交互玩法


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

    
      "System.Drawing.Common" Version="8.0.0" />
    

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

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

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

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

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

    咱们看看效果。

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

     

  • 相关阅读:
    【蓝桥杯省赛真题22】python剩余空间问题 青少年组蓝桥杯比赛python编程省赛真题解析
    DAY05_瑞吉外卖——新增套餐&套餐分页查询&删除套餐&短信发送&手机验证码登录
    NGINX源码之:目录导航
    深度学习系列53:mmdetection上手
    代码随想录训练营第53天|1143.最长公共子序列,1025.不相交的线,53.最大子数组和
    人工智能,丹青圣手,全平台(原生/Docker)构建Stable-Diffusion-Webui的AI绘画库教程(Python3.10/Pytorch1.13.0)
    CentOS7系统及内核升级
    Redis高并发分布式锁详解
    在MAUI中使用Masa Blazor
    基于HTML+CSS+JavaScript制作响应式个人博客模板源码( JavaScript期末大作业 )
  • 原文地址:https://www.cnblogs.com/tcjiaan/p/17908891.html