老周是一個不喜歡做界面的碼農,所以很多時候能用控制台交互就用控制台交互,既方便又占資源少。有大伙伴可能會說,控制台全靠打字,不好交互。那不一定的,像一些選項類的交互,可以用鍵盤按鍵(如方向鍵),可比用滑鼠快得多。當然了,要是要觸控的話,是不太好用,只能做UI了。 關於控制台交互,大伙伴們也許見得最多 ...
老周是一個不喜歡做界面的碼農,所以很多時候能用控制台交互就用控制台交互,既方便又占資源少。有大伙伴可能會說,控制台全靠打字,不好交互。那不一定的,像一些選項類的交互,可以用鍵盤按鍵(如方向鍵),可比用滑鼠快得多。當然了,要是要觸控的話,是不太好用,只能做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、逐像素獲取點陣圖的顏色,映射到控制台視窗的行、列中。如果像素是黑色,就輸出“**”,否則輸出“ ”(兩個空格)。
為什麼要用兩個字元呢?用一個字元它的寬度太窄,圖像會變形,只好用兩個字元了。漢字就不需要,一個字元即可。
咱們看看效果。
生成點陣圖時,尺寸不要太大,不然很占屏幕。畢竟控制台是以字元來計量的,不是像素。