轻松玩转windows控制台(六): 屏幕缓冲区的滚动机制

轻松玩转windows控制台(六): 屏幕缓冲区的滚动机制

首页休闲益智滚动白块更新时间:2024-06-05

写在前面

在上一篇文章中,详细地介绍了控制台屏幕缓冲区的基础概念,并通过代码例子演示了如何获取到当前屏幕缓冲区的相关数据,以及如何设置屏幕缓冲区的大小。(原文链接:)

因为控制台编程的知识极其繁多、复杂,本来打算写控制台其他知识点,屏幕缓冲区就暂时不再展开。但是有朋友说,显示在控制台窗口内的数据块(文本和背景),会被滚动显示,(左右滚动、上下滚动),会被剪切或填充。在控制台程序中时时刻刻会发生对屏幕缓冲区的各种操作,而且很容易迷惑,因为一种是文本本身的移动,一种是窗口的可见区域在变化,但屏幕缓冲区的文本其实并没有在变化等两种情况。

特别是屏幕缓冲区的文本的任意移动,是最常用的功能,特别是在一些控制台游戏中,会将经过的一些关卡“移走”,并填充为另外的文本,所以本文我们就来学习一下控制台屏幕缓冲区的滚动机制,并演示如何在实际中应用。

滚动机制(1):窗口变化

之前的文章已经多次介绍过,控制台窗口(Console Window Rectangle)和控制台屏幕缓冲区(Console Screen Buffer)之间的区别和联系。我们简单回顾下。

每个控制台程序都一定有一个或多个屏幕缓冲区,但始终只有一个是激活状态的(actived),注意,本文不探讨如何创建屏幕缓冲区,或创建多个屏幕缓冲区,也不探讨如何在多个屏幕缓冲区之间切换,也不探讨如何给控制台设置激活的缓冲区,我们之探讨激活状态的屏幕缓冲区和控制台矩形窗口之间的关系,因此后面的“幕缓冲区”都是指“激活状态的”屏幕缓冲区。

控制台程序会将要输出的数据放入到屏幕缓冲区,然后我们的电脑屏幕上,会有一个控制台程序的矩形窗口,这个矩形窗口会将屏幕缓冲区内的数据给显示出来。

屏幕缓冲区,是由行数和列数组成的。比如100行,每行200个字符。这就是屏幕缓冲区的尺寸。而控制台窗口只能小于或等于屏幕缓冲区,假设窗口尺寸30行,每行50个字符。

那么问题来了,窗口到底在初始状态下该显示屏幕缓冲区的哪一块区域?

正常情况下,都会显示到最后一行输出内容所在的行为最后一行的区域,或者光标所在的区域。如果屏幕缓冲区的内容大于窗口的尺寸,在窗口的右边框和底部边框都会出现滚动条,我们可以通过拖动滚动条,来显示屏幕缓冲区的其他内容。

但是,有时候我们想精准定位,在控制台程序的矩形窗口刚被显示时,比如我们希望这个矩形窗口的第0行第0列显示的是屏幕缓冲区的第20行第15列,矩形窗口的最后1个字符显示的是屏幕缓冲区的第49行第65列的字符。(窗口矩形的尺寸是30行50列)

无论屏幕缓冲区内此时对应的区域是否有数据,都会将窗口“移动”到该区域显示,如果没数据就只会显示黑洞洞的背景。

这时候,可以通过拖动滚动条查看屏幕缓冲区其他区域。这就是通过窗口的滚动,来查看屏幕缓冲区的数据,其实屏幕缓冲区的数据的先后顺序并没有变化。假设以屏幕缓冲区内的数据为参照物,移动的是矩形窗口。

通过下面两张来自MSDN的图片可以很清楚的说明:

黑色部分是窗口区域,灰色部分是屏幕缓冲区。当窗口的滚动条滚动时,起始屏幕缓冲区中数据并没有任何变化,变化的只是窗口,比如滚动一段时间后,就会出现窗口被移动屏幕缓冲区的底部附近,如图:

这个窗口的尺寸可以被不断改变,大小可以改变,或者只改变相对于屏幕缓冲区的位置也可以。有两种改变方式,如果相对于屏幕缓冲区的左上角顶点位置改变,称为绝对位置改变。如果相对于当前窗口的位置进行改变,那么改变的只是偏移量,不是绝对坐标,称为相对位置改变。

绝对位置移动

通过SetConsoleWindowInfo函数中的可以实现绝对位置改变,或相对位置改变。函数原型如下:

BOOL SetConsoleWindowInfo(HANDLE hConsoleOutput,BOOL bAbsolute,const SMALL_RECT *lpConsoleWindow);

这个函数在之前的文章中我们已经详细的讲解过。其中,第二个参数如果是TRUE,则对应的SMALL_RECT结构的坐标就是相对于屏幕缓冲区左上角顶点(0,0)的偏移量,我们称为“绝对位置移动”。

举个例子,代码如下:

#include <windows.h> #include <stdio.h> #include <stdlib.h> int main(){ HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); if (hOut == INVALID_HANDLE_VALUE) return GetLastError(); DWORD dwMode = 0; if (!GetConsoleMode(hOut, &dwMode)) return GetLastError(); dwMode |= 0x0004; if (!SetConsoleMode(hOut, dwMode)) return GetLastError(); for (int i = 0; i < 40; i) { printf("*****%d***************%d\n",i,i); } COORD cd = {260,100}; SetConsoleScreenBufferSize(hOut,cd); SMALL_RECT rc; rc.Top = 0; rc. Left = 0; rc.Bottom = 15; rc.Right = 15; SetConsoleWindowInfo(hOut, TRUE, &rc); //printf("\x1b[0;0H"); getchar(); CloseHandle(hOut); return 0; }

程序运行效果如图所示:

当拖动滚动条时,屏幕缓冲区其他区域的内容在窗口内显示出来,效果如下:

注意,即使屏幕缓冲区显示的区域没有数据,也会一样显示出来。

我们简单解释一下这个程序。

前面一段是异常处理代码,平时没有这段代码,也不影响程序的正常运行。但是如果要使用printf函数输出特殊功能,这段代码就必须要有。比如,printf("\x1b[0;0H"); 这个代码的作用就是将当前光标移动安东屏幕缓冲区的左上角(0,0)处。(相关用法,请参见之前的系列文章:),如果省略这段异常处理代码,printf就不能实现特殊功能。

for循环是输出一些数据到屏幕缓冲区。然后通过SetConsoleScreenBufferSize函数,将屏幕缓冲区的大小调整为100行,每行260个字符。

SMALL_RECT定义的矩形范围,就是控制台程序的窗口大小。Left和Top构成了左上顶点的(0,0)坐标。RIGHT和Bottom构成了右下顶点的(15,15)坐标。也就是说这个矩形窗口,呈现在显示器屏幕上的大小就是16行16列。

虽然屏幕缓冲区能显示100行*260列的数据,但是目前程序输出到屏幕缓冲区的只有40行 * 20个*和2个行值。

SetConsoleWindowInfo函数的第二个参数是TRUE,就表示矩形窗口的左上顶点和右下顶点就是绝对值。目前坐上顶点是(0,0),也就是说矩形窗口的起始位置就是屏幕缓冲区的第0行,第0列。矩形窗口显示的最后一个行,最后一个列,就是屏幕缓冲区的第15行第15列。程序运行截图也说明了这一点。

但是,特别的,当光标在输出for循环后,光标是停留在第41行的,如果你运行源码,没停留在期望的界面,而实停留在最后输出行的区域(Left和Right是生效的,有可能Top和Bottom没有期望的效果),那就把注释掉的printf函数打开,这行代码是强行将光标移动到屏幕缓冲区的(0,0)处(之前已经说过,必须要结合异常处理代码使用)。

最后,closeHandle函数释放句柄,类似于free函数释放指针一样。

相对位置移动

如果我们希望此时窗口能往下移动15行,就好像在自动滚动窗口内数据,类似自动翻页的效果,我们就需要使用相对位置移动的方法,将程序中SMALL_RECT变量rc的Top值设置为15,Bottom值设置为15,Lefit和Right设置为0,然后将SetConsoleWindowInfo的BOOL参数设置为FALSE,就是期望将当前窗口位置加上对应的偏移量,比如当前窗口的Top值为0,加上偏移量15,Bottom当前值为15,加上偏移量15,Left为0,加上偏移量0,Right当前值为15,加上偏移量0,最终得到的SMALL_RECT结构的新坐标是(0,15)和(15,30)。

我们可以试一下相对位置改变的效果,代码如下:

#include <windows.h> #include <stdio.h> #include <stdlib.h> int main(){ HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); if (hOut == INVALID_HANDLE_VALUE) return GetLastError(); DWORD dwMode = 0; if (!GetConsoleMode(hOut, &dwMode)) return GetLastError(); dwMode |= 0x0004; if (!SetConsoleMode(hOut, dwMode)) return GetLastError(); for (int i = 0; i < 40; i) { printf("*****%d***************%d\n",i,i); } COORD cd = {260,100}; SetConsoleScreenBufferSize(hOut,cd); SMALL_RECT rc; rc.Top = 0; rc. Left = 0; rc.Bottom = 15; rc.Right = 15; SetConsoleWindowInfo(hOut, TRUE, &rc); getCHAR();// rc.Top = 15; rc. Left = 0; rc.Bottom = 15; rc.Right = 0; SetConsoleWindowInfo(hOut, FALSE, &rc); printf("\x1b[0;0H"); getchar(); CloseHandle(hOut); return 0; }

程序运行后下效果如下:

和之前窗口在屏幕缓冲区内的相对位置比较,确实向下滚动了15行。注意第一个getChar函数被注释了,如果打开,会首先显示当前矩形窗口所在的(0,0)和(15,15)的区域:

暂停后再按任意键,停留在输出内容的最后位置所在的区域,并不是翻页的效果,这是因为受当前光标所在位置的影响。

滚动机制(2):文本移动

刚才的窗口滚动,确实窗口本身在滚动,而屏幕缓冲区的数据块的位置却保持不变。现在我们来看一下,如何让屏幕缓冲区内的数据块位置发生变化。

所谓屏幕缓冲区内数据块变化,举个例子,比如我们把屏幕缓冲区内某块矩形区域的数据,比如屏幕缓冲区内第2行,第3列为左上角,第5行第8列为右下角组成的区域内的数据(即便没有文本,也包含背景色),给移动到第10行,第4列为左上顶点的新区域。

这个时候就有这样几种情况。

第一,需要移动的区域,称为“scroll retangle”(被滚动的区域)。

第二,移动到区域称为“destination rantangle”(目标区域),新区域只需要提供左上角坐标即可,这个坐标被称为“destination origin coordinate”(目标区域的起始坐标),因为新区域和源区域必须一样大。

第三,源区域内文本和背景都移走后,会留下一块“空白“,该如何处理?

第四,如果目标区域超过屏幕缓冲区了,比如屏幕缓冲区为100行,200列,要移动的区域面积为30行,50列,新区域的起始位置为第95行,那么新区域肯定超过了屏幕缓冲区的最大行,该如何处理?

这里有一个非常重要的概念,就是“clip rectangle”,微软官方翻译成“剪辑区域”,我觉得还不如clip的另一个含义更容易理解:“加工的片段”,即“可以被加工的区域”,或“受影响的区域”,更容易理解。

所谓“受影响的区域”,就是将scroll rectangle的数据块剪切到(不是复制!)destination rectangle,目标区域会不会被覆盖,只取决于目标区域是不是在“受影响的区域”内,如果是“受影响的区域”,则会被覆盖,否则直接忽略该行为。

而滚动区域是否会留下空白,也要看其是否在“受影响的区域”内,如果不在,则不会留下空白,没有任何变化,如果是“受影响的区域”,那么就会留下“空白”。

下面我们来一一分析下。

剪辑区域等于整个屏幕缓冲区

对屏幕缓冲区内某一区域数据块移动到另一区域的操作,通常使用ScrollConsoleScreenBuffer函数来完成。函数原型如下:

BOOL ScrollConsoleScreenBuffer( HANDLE hConsoleOutput, const SMALL_RECT *lpScrollRectangle, const SMALL_RECT *lpClipRectangle, COORD dwDestinationOrigin, const CHAR_INFO *lpFill );

函数使用非常简单,第一个是控制台句柄,第二个是需要移走的区域,第四个是需需要移到的新区域的起始坐标。第三个参数和第五个参数需要结合起来讲解。

第三个参数是设置“剪辑区域”,也就是设置哪个区域会受这个函数操作的影响,如果值为NULL,那么就是整个屏幕缓冲区都是“clip rectangle”,都会受影响。否则就是屏幕缓冲区的某个局部区域是“clp rectangle”。

如果目标区域被包含在“clip rectangle”,那么就可以被覆盖,如果源区域被“clip rectangle”覆盖,那么就会被第5个参数的属性给覆盖。

第五个参数是一个CHAR_INFO结构变量,这个类型的定义如下:

typedef struct _CHAR_INFO { union { WCHAR unicodeChar; CHAR AsciiChar; }Char; WORD Attributes; } CHAR_INFO;

Char成员是一个联合体(共用体),无论选择unicode编码,还是ANSI编码,使用时给赋值一个字符,用以填充空白,Attributes的值是一个枚举常量,在之前文章后已经有详细的讲解,此处不再赘述。(链接)主要赋值为前景色和背景色,还有其他增强属性。受影响的源数据所在的区域,会被这个参数的值全部填充。

举个例子,代码如下:

#include <windows.h> #include <stdio.h> #include <stdlib.h> int main() { HANDLE hcon; hcon = GetStdHandle(STD_OUTPUT_HANDLE); SMALL_RECT scrollRect = {0, 1, 3, 2}; COORD destOrigion = {0, 4}; //移动位置 CHAR_INFO fillChar; fillChar.Char.AsciiChar = '*'; fillChar.Attributes = FOREGROUND_RED | BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_BLUE | FOREGROUND_INTENSITY; // fillChar.Attributes = csbi.wAttributes; printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"); printf("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n"); printf("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\n"); printf("DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD\n"); ScrollConsoleScreenBuffer(hcon, &scrollRect, NULL, destOrigion, &fillChar); //移动文本 getchar(); return 0; }

程序运行的效果如图:

这段代码非常简单,将第1行第0列为左上角,第2行第3列为右下角的区域作为scroll rectangle,将文本连同背景色一起移动到第4行第0列的目标起始位置所在的矩形区域。因为第三个参数为NULL,表明整个屏幕缓冲区都是受这个操作影响的区域。然后被剪走后留下的空白区域,被第五个参数填充。

下面这段代码用被剪切过的区域,再次被剪切的效果,可以清晰的理解“数据块”的含义,即操作的是“文本和背景”整体。

#include <windows.h> #include <stdio.h> #include <stdlib.h> int main() { HANDLE hcon; hcon = GetStdHandle(STD_OUTPUT_HANDLE); SMALL_RECT scrollRect = {0, 1, 3, 2}; COORD destOrigion = {0, 4}; //移动位置 CHAR_INFO fillChar; fillChar.Char.AsciiChar = '*'; fillChar.Attributes = FOREGROUND_RED | BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_BLUE | FOREGROUND_INTENSITY; // fillChar.Attributes = csbi.wAttributes; printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"); printf("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n"); printf("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\n"); printf("DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD\n"); ScrollConsoleScreenBuffer(hcon, &scrollRect, NULL, destOrigion, &fillChar); //移动文本 getchar(); scrollRect = {0, 1, 3, 2}; destOrigion = {0, 4}; //移动位置 fillChar.Char.AsciiChar = '#'; fillChar.Attributes = FOREGROUND_RED | BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_BLUE | FOREGROUND_INTENSITY; // fillChar.Attributes = csbi.wAttributes; ScrollConsoleScreenBuffer(hcon, &scrollRect, NULL, destOrigion, &fillChar); //移动文本 getchar(); return 0; }

程序运行的效果如图:

刚开始运行时和上面程序的效果一样,继续运行就不一样了,如图:

然后对剪切后的区域再次进行剪切,通过上面这张图就可以清楚的看到。

剪辑区域和移动区域

我们上面演示的都是把整个屏幕缓冲区作为“剪辑区域”对待,现在我们再进一步的细分下情况,如果目标区域不是“剪辑区域”,会是什么情况?

代码如下:

#include <windows.h> #include <stdio.h> #include <stdlib.h> int main() { HANDLE hcon; hcon = GetStdHandle(STD_OUTPUT_HANDLE); SMALL_RECT scrollRect = {0, 1, 3, 2}; COORD destOrigion = {0, 4}; SMALL_RECT clipRect = scrollRect; CHAR_INFO fillChar; fillChar.Char.AsciiChar = '*'; fillChar.Attributes = FOREGROUND_RED | BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_BLUE | FOREGROUND_INTENSITY; //fillChar.Attributes = csbi.wAttributes; printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"); printf("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n"); printf("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\n"); printf("DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD\n"); ScrollConsoleScreenBuffer(hcon, &scrollRect, &clipRect, destOrigion, &fillChar); //移动文本 getchar(); return 0; }

程序执行后的效果如图:

我们把scroll rectangle设置为clip rectangle,也就是说只有被移动的区域是可以“剪辑”的,其他区域包括目标区域都是“不受影响的”,所以被剪切后的空白区域继续可以被填充,而目标区域则没有任何变化。

要注意,第三个参数不能是NULL了,这个参数的矩形范围就是“clip rectangle”的范围,可以“受影响”的范围。

我们再来把scroll rectangle 设置为“不受影响的”,目标区域受影响,再看下效果。代码如下:

// // Created by china on 2024-02-16. // #include <windows.h> #include <stdio.h> #include <stdlib.h> int main() { HANDLE hcon; hcon = GetStdHandle(STD_OUTPUT_HANDLE); COORD dwSize = {200,100}; SetConsoleScreenBufferSize(hcon,dwSize); SMALL_RECT scrollRect = {0, 0, 3, 1}; COORD destOrigion = {0, 2}; SMALL_RECT clipRect; clipRect.Left = 0; clipRect.Top = 2; clipRect.Right = 3; clipRect.Bottom =3; CHAR_INFO fillChar; fillChar.Char.AsciiChar = '*'; fillChar.Attributes = FOREGROUND_RED | BACKGROUND_RED | BACKGROUND_GREEN | BACKGROUND_BLUE | FOREGROUND_INTENSITY; //fillChar.Attributes = csbi.wAttributes; printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"); printf("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n"); printf("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\n"); printf("DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD\n"); ScrollConsoleScreenBuffer(hcon, &scrollRect, &clipRect, destOrigion, &fillChar); getchar(); return 0; }

程序运行后的效果如图:

因为scroll区域不是“clip区域”,使得该区域无法被移动,所以目标区域也没有数据移动过来,即使可以被覆盖,也没有数据进行覆盖。

总 结

如果只是想实现窗口滚动,而不用对屏幕缓冲区的数据进行操作,就可以使用窗口机制。如果需要对屏幕缓冲区的数据进行操作,就要用到文本移动机制。

如果用窗口滚动机制时,要注意分清楚是菜用绝对位置移动,还是相对位置移动。

如果是文本移动模式,一定要注意,不能把scroll区域包含在非剪辑区域,否则函数提示成功执行,却无法吧数据移动到其他位置,这种隐型的BUG会很难排查,如非必要,一般都将剪辑区域设置为NULL模式,这样整个屏幕缓冲区都可以被“剪辑”。

目标区域可以是非剪辑区域,只要scroll区域是剪辑的即可,这样数据会被剪走,只是不会覆盖目标区域。

段誉,2024年2月16日,写于合肥。

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved