起因

画布(Screen)之上没有任何 UI 基础设施——没有窗口系统、没有字体、没有控件库,全部自己建。要建的东西里最老的两样各有来历:画斜线的 Bresenham 算法是 1962 年 Jack Bresenham 在绘图仪时代发明的,整数增量、零浮点——那年头浮点运算贵得要用在刀刃上;中文显示走点阵则踩着 1980 年代国产微机汉卡的老路,当年在几百 KB 内存里显示汉字,点阵是唯一现实的选择。这篇把图形系统搭起来:2D 渲染原语、中文点阵字体、控件模型、事件分发,全部跑在协作式主循环里。目标界面在写代码前就该想清楚:一块坐标/进度区、一排规准与状态灯、一条报警栏、几个按钮和编辑框——就这些,没有窗口拖拽没有多层叠加。工业界面长什么样,决定这套系统长什么样:中文、高对比、状态一目了然。

一、渲染原语:够用就好的一小撮

在后备缓冲(width 步进)上实现,常用的就这几个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 水平/垂直线:fill_rect 的特化
pub fn hline(&mut self, x0: usize, y: usize, w: usize, c: u32) { ... }

// Bresenham 直线:整数运算,无浮点,斜线用它
pub fn line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, c: u32) {
    let (dx, dy) = ((x1-x0).abs(), -(y1-y0).abs());
    let (mut x, mut y) = (x0, y0);
    let (sx, sy) = (sign(x1-x0), sign(y1-y0));
    let mut err = dx + dy;
    loop {
        self.set(x as usize, y as usize, c);
        if x == x1 && y == y1 { break; }
        let e2 = 2 * err;
        if e2 >= dy { err += dy; x += sx; }
        if e2 <= dx { err += dx; y += sy; }
    }
}

// 位图块搬运:把一块像素数据贴上去(图标、字模都靠它)
pub fn blit(&mut self, x: usize, y: usize, w: usize, h: usize,
            src: &[u8], pal: &[u32]) { ... }   // 1bit 字模走调色板展开

脏矩形(dirty rect)是性能的命门:每帧只重绘变化的矩形区域并只拷贝那几行上屏。1080p 全刷是 8MB,一个 200×40 的状态数字刷新是 32KB——差 250 倍。控件标记自己脏,present 只拷脏矩形的行区间;同帧多个脏矩形先按 y 排序、合并相邻的,避免同一行来回拷三遍。静态工控界面下这个设计几乎免费。

坑:所有原语入口先裁剪再画。 控件传进来的矩形越界(负坐标、超宽高)时,set 不是 panic 就是把像素写到别人控件里——每个绘制原语第一行都是跟画布求交,越界部分静默丢弃。调试期留一个"越界即记日志"的断言版本,发布版关掉。

二、中文字体:从一个字怎么变成屏幕上的点讲起

先看一个字是怎么显示出来的。 屏幕上一个汉字,本质是一张小位图贴到画布上:16×16 的字模,每行 16 个点用 2 字节存(1 bit 一个点),16 行共 32 字节。位是 1 画前景色,是 0 留背景。所以"显示一个字"落到代码上就三步:查到这 32 字节 → 逐行逐位扫 → 是 1 就往后备缓冲写一个像素。8×16 的半角字符同理,每行 1 字节、共 16 字节。为什么用点阵不用矢量:TrueType 那类矢量字库存的是轮廓,显示前要做光栅化(贝塞尔求交、抗锯齿),是一整个渲染引擎的工程量;点阵直接存"已经光栅化好"的结果,工控界面 16px 的字号,点阵的显示效果完全够,换来的是渲染代码几十行。这一步想清楚了,“显示"的问题就只剩一个:几万个字的字模数据怎么组织、怎么查。

字库怎么构成。 核心是"码位 → 字模"这张查找表。最朴素的办法是按码位直接开偏移:CJK 基本区 0x4E00–0x9FFF 连续,offset = (cp - 0x4E00) * 32 就能查。这对成片的大块有效,但拉丁、西里尔、CJK 在码位空间里东一块西一块,按整个 Unicode 平面开稀疏数组是行不通的浪费。实际用两级稀疏索引:

1
2
3
4
5
6
7
文件头: magic "UF16" / 字模宽高 / block 数
block 表(按码位高 8 位索引,最多 256 项):
    start_cp: u16    // 该 block 覆盖的起始码位
    count:    u16    // 连续覆盖多少个
    width:    u8     // 8=半角 16=全角
    offset:   u32    // 字模数据区起始
字模数据: 各 block 连续堆放,每字 32 字节(半角 16 字节)

查一个字:blk = BLOCKS[cp >> 8],码位落在 [start_cp, start_cp+count) 里就取 offset + (cp - start_cp) 的字模,查不到给固定的”□“兜底字模——覆盖范围在生成字库时就定了,运行期不会失败。

字模数据从哪来:从一款开源字体生成。用 Noto Sans CJK SC(SIL OFL 许可,明确允许商用、允许栅格化衍生),写个离线脚本按 16px 栅格化出点阵,按上面的格式输出二进制。CJK 基本区全收是 20902 字 ≈ 669KB;只收常用 7000 字 ≈ 224KB——图省心就收全,嫌内核体积就收常用的。生成脚本是一次性离线工具,产物二进制 include_bytes! 编进内核,跟零依赖策略一条线:字库跟着内核走,不依赖磁盘文件,启动即有 UI。

1
2
3
4
5
6
7
8
9
pub fn draw_text(&mut self, x: usize, y: usize, s: &str, fg: u32) {
    let mut cx = x;
    for ch in s.chars() {                 // &str 本身就是 UTF-8,chars() 直接给码位
        let cp = ch as u32;
        let (g, w) = font_lookup(cp);     // 两级索引,缺字给 "□"
        self.blit_1bit(cx, y, w, 16, g, fg);
        cx += w;
    }
}

最后才是编码。 字库按 Unicode 码位组织了,字符串侧就顺势定死:系统内一切文本 UTF-8。&strchars() 迭代直接产出码位,喂给两级索引,中间不需要任何码表转换。要守的纪律只有一条:Rust 的字符串切片按字节算,&s[i..j] 下标切在中文多字节中间会直接 panic——一切文本处理按 chars() 迭代走,宽度信息(全角 16 / 半角 8)跟着码位从 block 表查。这条纪律在国际化篇还要接着用。

顺带把一个老坑埋掉:商用点阵字库(方正、汉仪这些)的授权雷区在工控圈出名地多。从 OFL 字体自己生成点阵,版权问题就从"买授权"变成了"没有问题”。

三、控件模型:enum 分发,不搞继承

最小控件集合:窗口、按钮、标签、编辑框、列表、状态栏。Rust 没有继承,也不需要——每个控件是"一块矩形 + 一个绘制函数 + 一个事件处理函数":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pub struct Widget {
    pub rect: Rect,                       // 矩形 + 脏标记
    pub kind: WidgetKind,                 // enum:Button{..} / Label{..} / Edit{..} ...
    pub dirty: bool,
    pub on_event: Option<fn(&mut Widget, &Event) -> Handled>,
}

pub enum Event {
    MouseMove { x: i32, y: i32 },
    MouseDown { x: i32, y: i32, button: u8 },
    MouseUp   { x: i32, y: i32, button: u8 },
    KeyDown   { code: KeyCode, shift: bool, ctrl: bool },
    KeyUp     { code: KeyCode },
}

控件集合扁平化:一个 Vec<Widget> 顺序遍历,不做嵌套控件树——工控界面没有层叠窗口,命中测试就是线性扫一遍矩形,几十个控件根本用不上树。每个控件自带视觉状态:按钮分常态/悬停/按压/禁用四套配色,编辑框有 500ms 闪烁的光标(它就是个自带定时脏标记的小控件)。布局用坐标常量 + 简单锚点(贴边、居中),不做通用布局引擎。配色高对比:车间强光环境,前景白/背景深灰起步,报警红、运行绿、参数黄各自固定,颜色即语义——操作工扫一眼颜色就知道机器状态,这比任何动画都有用。

四、事件分发与渲染节律

输入来自 HID 篇的 SPSC 队列,图形系统作为协作层的一个 step 函数跑:

1
2
3
4
5
6
7
8
pub fn ui_step(ui: &mut Ui) {
    // 1. 清空输入队列:命中测试 → 焦点控件 → 回调(都在协作层,不在中断里)
    while let Some(ev) = INPUT_QUEUE.pop() {
        ui.dispatch(&ev);           // 找到 (x,y) 落在哪个控件矩形里,调用其 on_event
    }
    // 2. 重绘脏控件到后备缓冲
    if ui.any_dirty() { ui.redraw_dirty(); ui.present_dirty(); }
}

两条节律纪律:

  • 加工状态刷新 10–20Hz 封顶:状态回传队列(调度篇)里的坐标、电流、进度由状态栏消费,刷新够快人眼就认为实时——省下的 CPU 时间留给控制和 IO,不追 60fps。鼠标 HID 是 8ms 一次中断上报(键鼠篇),ui_step 以 10ms 节拍消化,队列天然削峰,两边节拍解耦
  • 回调永远不在中断上下文执行:HID 中断只往队列塞事件,UI 逻辑全部在协作层消化——这是两级模型在图形系统的投影

总结

实战清单:hline/line/blit 原语 + 脏矩形机制、Unicode 两级稀疏索引的生成式点阵字库 + draw_text 按码位迭代、enum 分发的控件模型 + 高对比语义色、ui_step 里"清队列→分发→脏重绘"的固定节拍。跑通标志:屏幕上出现中文按钮,鼠标(HID 篇完成后)能点出按压态,状态栏以 15Hz 刷新不闪烁;混排一行中英文和西里尔文不乱码。下一篇去挖总线:PCIe 枚举,找到那块运动控制卡。

参考链接