Day 27 已经把鼠标字节变成坐标、按键和边沿,但屏幕上仍然只有一个直接重绘的字符终端。窗口管理要再回答三件事:重叠位置显示谁,点击交给谁,拖动过程中谁持续拥有指针。

本篇实现一个固定双窗口管理器。窗口表保存几何和文本,Z 序单独保存遮挡关系;命中从顶向底查找,绘制从底向顶执行。左键按下标题栏后建立拖动捕获,键盘字符只写入焦点窗口。每次状态变化仍重建完整 320×200 画面,以最小机制先验证所有权和遮挡恢复。

窗口表与 Z 序承担不同职责

两个窗口的内容放在固定表中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct demo_window {
int32_t x, y;
uint32_t width, height;
uint8_t body_color;
char name;
char text[8];
uint32_t text_length;
};

struct demo_window windows[2] = {
{20, 40, 150, 100, 1, 'A', "", 0},
{105, 70, 180, 110, 2, 'B', "", 0},
};
int z[2] = {0, 1};

windows[0] 始终表示 A,移动或聚焦不会改变窗口身份。z 保存从底到顶的窗口编号,所以初始 [0,1] 表示 B 在 A 上面。把对象身份和排列顺序分开以后,提升窗口只需改 Z 序,无须搬动窗口内容,也不会让外部引用因为数组换位而失效。

绘制与命中必须使用相反方向:

1
2
3
4
5
6
for (unsigned i = 0; i < WINDOW_COUNT; ++i)
window_draw(surface, &windows[z[i]], z[i] == focused);

for (int i = WINDOW_COUNT - 1; i >= 0; --i)
if (point_in_window(&windows[z[i]], x, y))
return z[i];

底层窗口先画,顶层窗口后画,后写入的像素自然覆盖前者。鼠标点击则先问顶层;重叠点不能穿透到下层窗口。两条遍历共享同一份 z,避免显示顺序和事件顺序各维护一份状态后逐渐分叉。

初始帧完成后,GDB 同时读取模型和真实 framebuffer。坐标 (120,90) 位于两个窗口的重叠区,最终颜色是 B 的客户区颜色 2:

1
INITIAL A=20,40 B=105,70 focus=1 z=0,1 overlap=2

可迁移的做法是“稳定对象表 + 独立顺序表”。当对象本身有稳定身份,而显示、调度或优先级经常改变时,索引顺序比搬动整个对象更容易维持引用有效性。

点击同时更新焦点和遮挡

焦点决定键盘事件的接收者,Z 序决定显示和鼠标命中的优先级。这个演示采用常见的 click-to-focus 策略:左键从抬起变为按下时,先命中最上层窗口,再更新焦点并把它提升到 Z 序末尾。

1
2
3
4
5
6
7
8
9
10
if ((event.buttons & 1) && !(previous_buttons & 1)) {
int hit = window_hit(windows, z, mouse_x, mouse_y);
if (hit >= 0) {
if (focused != hit) {
focused = hit;
++focus_changes;
}
window_raise(z, hit);
}
}

处理条件是按键边沿,不是“左键当前仍按着”。如果每个移动包都重复执行聚焦和置顶,拖动会制造无意义的状态变化,也会掩盖真正发生了几次焦点切换。

专项序列先点击 A,再点击 B。初始焦点已经是 B,因此两个动作恰好产生两次焦点变化和两次 Z 序变化。最终状态中的 changes=2/2 直接核对这两个计数,没有用最终截图推测中间发生过什么。

键盘路径只读取一个拥有者:

1
2
3
4
5
struct demo_window *window = &windows[focused];
if (window->text_length + 1 < sizeof(window->text)) {
window->text[window->text_length++] = (char)character;
window->text[window->text_length] = 0;
}

A 获得焦点后输入 a,B 再次获得焦点后输入 b。GDB 最终读取两份独立缓冲为 a/1b/1。共享键盘队列只产生一次字符事件,窗口管理器负责把它交给当时的唯一焦点。

拖动需要捕获,而不是连续命中

标题栏高度固定为 16 像素。左键在标题栏按下时,窗口管理器记录窗口编号和鼠标相对窗口左上角的偏移:

1
2
3
4
5
if (mouse_y < windows[hit].y + WINDOW_TITLE_HEIGHT) {
dragged = hit;
drag_offset_x = mouse_x - windows[hit].x;
drag_offset_y = mouse_y - windows[hit].y;
}

捕获建立后,后续移动继续作用于同一个窗口。指针可以离开标题栏,也可以经过另一个窗口;拖动对象不会重新命中。新的位置由当前指针减去按下时的偏移得到,因此窗口不会在按下瞬间把左上角跳到指针位置。

1
2
3
4
5
6
int32_t next_x = mouse_x - drag_offset_x;
int32_t next_y = mouse_y - drag_offset_y;
next_x = clamp(next_x, 0, 320 - window->width);
next_y = clamp(next_y, 0, 200 - window->height);
window->x = next_x;
window->y = next_y;

当前策略要求整个窗口留在屏幕内。A 的起点是 (20,40),按下点为 (40,45),捕获偏移是 (20,5);指针再移动 (30,20) 后到达 (70,65),所以 A 的新位置为 (50,60)。松开左键立即把 dragged 设为 -1,后续移动不再改变窗口。

拖动捕获是一种临时所有权。按下时选择接收者,释放前沿用这个选择,释放后清空。滚动条、滑块、框选和触摸手势也需要同样的状态,否则指针一离开最初的小区域,操作就会突然丢失。

遮挡恢复来自场景重放

窗口移走后,旧位置上存着的像素已经混入窗口、背景和光标。直接从旧显存复制无法判断每个像素原来属于谁。本篇保留完整场景状态,每次变化按固定顺序重建:

1
2
3
4
5
6
7
桌面背景

Z 序中的窗口:底层到顶层

鼠标指针

64,000 字节复制到 VGA

对应代码先把整个离屏 surface 填成桌面色 3,再画两个完整窗口,最后画鼠标并 flush。A 从 (20,40) 移走后,测试直接读取旧左上角,得到背景色 3;新的重叠点 (120,90) 得到顶层 B 的客户区颜色 2:

1
2
FINAL A=50,60 B=105,70 focus=1 top=1 drag=-1
changes=2/2 text=a/b pixels=3/2 packets=8 redraws=10

pixels=3/2 分别是旧位置和重叠位置的实测值。前者证明旧窗口痕迹消失,后者证明最终遮挡顺序正确。截图只提供整体画面,像素断言负责把“看起来对”收紧为可重复检查:

A 被拖动后,B 重新获得焦点并覆盖重叠区域

全量重绘也有明确成本:每次 flush 固定复制 64,000 字节。本次九条 HMP 命令产生 8 个鼠标包,连同字符变化共完成 10 次重绘。这是固定输入序列的计数,不是延迟或吞吐基准;没有测量结果支持之前,脏矩形只会增加一套新的区域合并和失效状态。

一条事件的完整路径

窗口代码没有再次读取 0x60。Day 27 的共享入口仍负责 IRQ1/IRQ12 分流,鼠标解析器输出归一化事件,窗口循环才改变 UI 状态:

1
2
3
4
5
6
7
8042 输出缓冲
→ IRQ1 / IRQ12 单点读取与分流
→ 键盘字符队列 / 鼠标事件
→ 更新指针与按键边沿
→ 顶层命中、焦点、Z 序、拖动捕获
→ 重建离屏 surface
→ 刷新 VGA framebuffer

这个边界让硬件协议和窗口策略保持独立。PS/2 解析器只关心三字节包,窗口管理器只接收 dxdy 和按钮状态;将来替换成其他指针设备时,窗口状态机不需要理解新的原始报文。

实现目前仍在一个内核演示循环中,没有创建独立 UI 任务。事件处理、模型更新和重绘按单 CPU 顺序执行,所以不需要窗口锁;这个性质不能外推到多核或用户态图形服务。

实验记录与累计回归

专项检查结果初始状态与重叠像素最终模型与 framebuffer 断言串口记录完整证据说明保存了本篇运行数据。

专项测试之后,Day 27 鼠标、Day 26 图形文字、Day 25 图形、保护模式、管道、Shell、文件描述符、FAT16、ATA、进程、系统调用、ELF、物理页、加载容量和 IRQ1 键盘检查全部退出 0,合并记录见累计回归日志。冻结 ZIP 又从没有 build/ 的空目录完成构建、专项和同一轮累计复验,收据见独立附件复验

当前边界

窗口数量固定为 2,标题栏固定为 16 像素。实现没有创建、关闭、隐藏、缩放窗口,也没有动态内存所有权和销毁竞态。窗口整体被限制在屏幕内,尚未提供只保留标题栏可见的屏外策略。

键盘文本只是每窗口 7 字节缓冲,没有光标编辑、滚动或终端端点。窗口管理代码运行在内核演示循环中,不是用户进程可调用的 GUI 协议。Day 29 才会把既有用户态 Shell 的标准输入输出接到窗口终端。

验证环境仍限定为 QEMU 8.2.2、SeaBIOS 1.16.3、pc-i440fx-7.2、TCG、qemu32、1 CPU 和 64 MiB。HMP 能稳定复现设备输入,但不能替代物理鼠标手感、真实 VGA/PS/2 硬件或性能测量。

机制速查

遇到的问题 可迁移做法 本篇落点
对象身份稳定,显示顺序频繁变化 对象表与顺序表分离 windows[]z[]
显示和输入都依赖遮挡关系 同一顺序正向绘制、反向命中 底到顶画,顶到底查
拖动中指针离开起始区域 按下时捕获,释放时解除 dragged 与相对偏移
多个窗口共享键盘 单一焦点决定事件接收者 focused 路由字符
覆盖物移走后要恢复底层 从模型重建,不复用被污染像素 背景、窗口、鼠标全帧重画
优化会引入更多失效状态 先取得可测基线,再决定是否优化 64,000 字节 flush 与重绘计数

下载与复现

下载第 28 篇完整源码。附件 SHA-256:

1
59b671726bb7de5c55d9c7a54b9a6d60dd5a6a7b9d457b8eca6008f316a858a1

解压后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
unzip os-day-28.zip
cd os-day-28
make build
make check-window
make check-mouse
make check-graphics-text
make check-graphics
make check-pm
make check-pipe
make check-shell
make check-fd
make check-fat
make check-block
make check-process
make check-syscall
make check-elf
make check-memory
make check-runtime-limit
make check-keyboard

附件包含第 01–28 篇累计工程和打包前形成的 docs/day28-* 证据,不含 build/。冻结后在新的空目录重建并复跑全部检查;day28-zipcheck.txt 是附件自身的复验收据,不写回 ZIP。

练习

  1. 在客户区按住左键后移动,确认焦点会变化但窗口不会跟随指针;再与标题栏拖动对照。
  2. 把 A 拖到四条屏幕边界,检查整个窗口仍在 [0,320) × [0,200) 内,且鼠标偏移不跳变。
  3. 将 Z 序改成三个窗口,用同一份顺序分别完成绘制和反向命中,验证任意重叠点只交给最上层对象。
  4. 合并一批连续移动后再刷新,记录重绘次数与最终位置;不要只凭计数宣称性能改善。

上一篇:27 - 鼠标输入

下一篇:29 - 窗口中的用户态 Shell。

参考资料