跳转至

第三阶段

选做阶段

本阶段为选做。

不过本阶段仍然分有必做和选做任务,我们推荐你完成本阶段的必做任务。

引入

在完善了框架组件之后,我们总算具备了实现一个具有实际意义的窗口应用的全部基础设施。因此在本阶段,我们将实现第一个 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 文件,我们直接拒绝解析它即可。

因此,我们只需要关心文件头和信息头中的两个变量,即 biWidthbiHeight,其他全部都不用管。

像素的数据结构可以是:

// 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

如果运行后