第三阶段¶
选做阶段
本阶段为选做。
不过本阶段仍然分有必做和选做任务,我们推荐你完成本阶段的必做任务。
引入¶
在完善了框架组件之后,我们总算具备了实现一个具有实际意义的窗口应用的全部基础设施。因此在本阶段,我们将实现第一个 minitui
窗口应用——文件查看器。
文件查看器具备如下功能:
- 显示
storage
目录下的文件列表 - 允许用键盘和鼠标选定文件,高亮当前选定的文件。
- 支持选定后展示文件,支持格式
.txt
和.bmp
。
任务描述¶
本阶段的必做代码任务分为四个:
- 实现 bmp 图片的显示。
- 实现 Shift + Tab 切换焦点窗口至上一个焦点窗口。
- 实现图片展示框组件。
- 实现文件查看器,可展示
storage
下文件列表及展示选定文件(如支持)。
代码任务一:显示 bmp 格式图片¶
引入¶
要实现上述支持 bmp 图片显示的文件查看器,首先我们要能够在终端中(而未必要使用 minitui
)显示一个 bmp 文件。
按照「把大象装进冰箱」的做法,我们只需要做这三件事:
- 根据文件名打开 bmp 文件。
- 解析 bmp 文件的数据为像素矩阵。
- 将每个像素按顺序打印出来。
OK,第一步我会,fopen
嘛!
第二步……怎么解析文件来着,sscanf
?
完了,这玩意好像不是文本,搞不会。还是问 GPT 吧。
看来我们遇到了困难,这里有必要介绍一下如何解析一个二进制文件。
解析二进制文件¶
文件:字节序列¶
注:本节只讨论一般的数据文件,特殊文件不在讨论之列。
和任何计算机数据一样,文件数据存在的形式很简单——一段二进制字节序列。
在程序中,我们可以用字符数组来表示和存储这样的二进制序列。
因此,我们可以用 fread
来以字符数组的形式读取任意文件的数据:
// 文件数据可能很大,因此我们把它分成若干段长度最多为 4096 字节的字符数组
char data[4096];
// 打开文件,r 表示读取,b 表示二进制模式
auto fp = fopen("some_file", "rb");
/*
fread(
void *, // 数组首地址
size_t, // 每个元素的大小(字节计),这里是 1
size_t, // 读取的最大个数
FILE * // 目标文件指针
)
*/
int bytes = fread(data, 1, 4096, fp); // 返回成功读取的个数
while (bytes > 0) {
// do something to `data`
// fread 会从上次读取的最后一个字节之后继续读取
bytes = fread(data, 1, 4096, fp);
}
文件:变量数据的排列¶
如果我们读取的是一个文本文件,那么不需额外解析——在计算机中,文本(字符串)本来就是一个字节数组,按照字符编码解码即可。
那要用它表示其他变量/对象呢?也很简单——我们知道,在计算机中,所有变量/对象本身也是用一个字节序列表示的,也就是可以用一段字符数组来表示。
可以看到,文本字符串也好,其他变量/对象也好,在内存中和在文件中的表示形式——字节序列——都没有区别,那么,要从文件中获取一个变量/对象,就只要把这串字节序列从文件里搬到内存里就好了。
要这么做,同样可以使用 fread
——只需将变量的首地址作为“字符数组”首地址就好了。
// 以二进制模式打开文件
FILE *fp = fopen(fn, "rb");
some_type some_obj;
/*
fread(
void *, // 数组首地址
size_t, // 每个元素的大小(字节计),这里是 1
size_t, // 读取的最大个数
FILE * // 目标文件指针
)
*/
// 从文件中读取对象数据
fread(&some_obj, sizeof(some_type), 1, fp);
// 关闭文件
fclose(fp);
不过,文件数据可能是由多个简单或复合变量共同构成的,我们可以按上面的方法把它们逐个地读取到内存中。
文件格式:文件数据的结构¶
不过,我们怎么知道文件数据里面有哪些变量/对象呢?它们又是以什么样的顺序排列的呢?
这些由文件的文件格式定义,也可以称为文件类型。
文件格式就像数据类型,能够定义文件数据的结构。
文件格式可能体现在文件名中,比如 xx.bmp
是一个 BMP 格式的文件,yy.txt
是一个文本格式的文件,等等。
还有一些文件格式会规定文件数据的头几个字节的内容,我们一般把这头几个字节称作魔数。
不过,就和内存中的数据其实可以按照不同的数据类型解读一样,其实也没有绝对的判定一个文件的文件格式的方法。一般来说,我们会根据文件名或魔数或其他信息,先入为主或约定俗成地认定一个文件的格式,然后去按照该格式读取它。
总之,只要选定一种文件格式,辅以前面的 fread
函数,我们就可以正确地将一个文件的数据读取出来,或至少判断出其数据不符合该文件格式。
BMP 文件格式¶
现在我们回到正题——解析 BMP 文件。
由上文,要解析某格式的文件,我们首先需要了解该格式描述的数据结构。
BMP 是一种储存图像的文件格式,其名来源于 BitMaP (位图)。
我们知道,计算机图像其实就是二维的像素矩阵,要表示这个像素矩阵,只需知道其行数、列数和每个像素的值就可以了。
BMP 表示图像的思路就是这样的:首先在开头表示出图片的行、列,然后在后面列出所有像素的数据就可以了。
不过事实上,由于表示像素的方式有很多种,所以 BMP 还需要在开头存下表示像素的方式,甚至一些调色盘信息,是否压缩像素数据等信息,使得其文件头变得很复杂,有足足 54 个字节。
一般来讲,我们将 BMP 的文件头划分成两个对象——文件头和信息头,这两个对象的类型分别是:
// BMP 文件头数据结构
struct tui_bmp_header{
uint16_t bfType; // 魔数,0x4d42 "BM"
uint32_t bfSize; // 文件大小
uint32_t bfReserved; // 总是为零
uint32_t bfOffBits; // 像素数据在文件中的位置
}__attribute__((packed));
// BMP 信息头数据结构
struct tui_bitmap_info
{
uint32_t biSize; // 头的大小
uint32_t biWidth; // 图片的宽度
int32_t biHeight; // 图片的高度
uint16_t biPlanes; // 总是为 1
uint16_t biBitCount; // 色深,每个像素有多少位
uint32_t biCompression; // 压缩方式
uint32_t biSizeImages; // 图片有多大
int32_t biXPelsPerMeter; // X 轴分辨率
int32_t biYPelsPerMeter; // Y 轴分辨率
uint32_t biClrUsed; // 调色盘用了多少种颜色
uint32_t biClrImportant; // 有多少种颜色是重要的
}__attribute((packed));
为了简化任务,我们只支持一类目前最常见的 BMP:
- 色深(
biBitCount
)为 24,每个颜色为三个字节。 - 没有调色盘,像素数据直接就放在信息头后面,即
bfOffBits == 0x36
。 - 数据区不设置压缩,即
biCompression
为零。
如果遇到不满足条件的 BMP 文件,我们直接拒绝解析它即可。
因此,我们只需要关心文件头和信息头中的两个变量,即 biWidth
,biHeight
,其他全部都不用管。
像素的数据结构可以是:
// BMP 像素(24 位色)数据结构
struct tui_bmp_pixel_24{
uint8_t b;
uint8_t g;
uint8_t r;
}__attribute__((packed));
像素矩阵类¶
为了方便对像素矩阵的存储与操作,我们将像素矩阵包装成一个类:
struct tui_pixelmap {
int width;
int height;
int byte_per_pixel;
uint8_t **bitmap;
tui_pixelmap(int width, int height, int byte_per_pixel=3);
void *at(int x, int y);
void set_pixel(int x, int y, uint8_t *pixel);
template <rgb_color color_t>
color_t *at(int x, int y) {
tui_assert(sizeof(color_t) == byte_per_pixel);
return (color_t *) at(x, y);
}
// 返回 (x, y) 处的像素
template <rgb_color color_t>
color_t get_pixel(int x, int y) {
tui_assert(sizeof(color_t) == byte_per_pixel);
return *(color_t *) at(x, y);
}
// 设置 (x, y) 处像素
void set_pixel(int x, int y, rgb_color auto color) {
tui_assert(sizeof(color) == byte_per_pixel);
uint8_t *pixel = (uint8_t *) &color;
set_pixel(x, y, pixel);
}
template <rgb_color color_t>
void set_pixel(int x, int y, uint8_t r, uint8_t g, uint8_t b) {
tui_assert(sizeof(color_t) == byte_per_pixel);
color_t color;
color.r = r, color.g = g, color.b = b;
set_pixel(x, y, color);
}
// 获取像素矩阵**终端相对位置** (x, y) 上的前景色/背景色
template <rgb_color color_t>
tui_formatter get_formatter(tui_point point) {
tui_assert(byte_per_pixel == sizeof(color_t));
// task phase3: generate formatter in this position
}
// 解析 `filename` 指定的 bmp 文件并返回一个像素矩阵
static tui_pixelmap *from_bmp(const char *filename);
~tui_pixelmap();
};
我们这里留了一个空,因为在终端上,我们用一个位置来表示两个像素,其中该位置的前景色用于表示上面的像素,背景色用于表示下面的像素,因此在
解析 BMP 为像素矩阵¶
在像素矩阵类中,已经预留了一个将 bmp 转化为
tui_pixelmap *
tui_pixelmap::from_bmp(
const char *filename
) {
FILE *fp = fopen(filename, "rb");
if (!fp) {
Warn("Cannot open file %s", filename);
fclose(fp);
return NULL;
}
tui_bmp_header header;
// 读取 BMP 文件头
fread(&header, sizeof(header), 1, fp);
// 检查魔数
if (header.bfType != 0x4d42) {
Warn("Not a bmp file!");
fclose(fp);
return NULL;
}
if (header.bfOffBits != 0x36) {
Warn("Do not support bmp file with offset %d", header.bfOffBits);
fclose(fp);
return NULL;
}
tui_bitmap_info info;
// 读取 BMP 信息头
fread(&info, sizeof(info), 1, fp);
// 检查
if (info.biSize != 40) {
Warn("Only support 40 bytes info header!");
fclose(fp);
return NULL;
}
if (info.biBitCount != 24) {
Warn("Only support 24-bit bmp file!");
fclose(fp);
return NULL;
}
if (info.biPlanes != 1) {
Warn("Only support 1 plane bmp file!");
fclose(fp);
return NULL;
}
if (info.biCompression != 0) {
Warn("Only support uncompressed bmp file!");
fclose(fp);
return NULL;
}
if (info.biClrUsed != 0) {
Warn("Do not support bmp file with color table!");
// just ignore it
}
if (info.biClrImportant != 0) {
Warn("bmp file with important color!");
// just ignore it
}
// task phase3: read the pixel data according to the rest of the file
fclose(fp);
// task phase3: fill it
return NULL;
}
运行测试¶
我们已经在 test/bmp/
下为你准备好了测试程序。
这个测试程序不是个 minitui 程序而只是一个普通的命令行程序,因为它不运行 tui_init()
和 tui_exec()
之类的接口。
但是它引用了 minitui 的类库,可以对 minitui 类库的正确性进行测试。
只需这么运行测试:
make -j8 game STAGE=test/bmp
如果运行后