从零编写操作系统 28 - 窗口管理:Z 序、焦点与拖动
Day 27 已经把鼠标字节变成坐标、按键和边沿,但屏幕上仍然只有一个直接重绘的字符终端。窗口管理要再回答三件事:重叠位置显示谁,点击交给谁,拖动过程中谁持续拥有指针。
本篇实现一个固定双窗口管理器。窗口表保存几何和文本,Z 序单独保存遮挡关系;命中从顶向底查找,绘制从底向顶执行。左键按下标题栏后建立拖动捕获,键盘字符只写入焦点窗口。每次状态变化仍重建完整 320×200 画面,以最小机制先验证所有权和遮挡恢复。
窗口表与 Z 序承担不同职责
两个窗口的内容放在固定表中:
1 | |
windows[0] 始终表示 A,移动或聚焦不会改变窗口身份。z 保存从底到顶的窗口编号,所以初始 [0,1] 表示 B 在 A 上面。把对象身份和排列顺序分开以后,提升窗口只需改 Z 序,无须搬动窗口内容,也不会让外部引用因为数组换位而失效。
绘制与命中必须使用相反方向:
1 | |
底层窗口先画,顶层窗口后画,后写入的像素自然覆盖前者。鼠标点击则先问顶层;重叠点不能穿透到下层窗口。两条遍历共享同一份 z,避免显示顺序和事件顺序各维护一份状态后逐渐分叉。
初始帧完成后,GDB 同时读取模型和真实 framebuffer。坐标 (120,90) 位于两个窗口的重叠区,最终颜色是 B 的客户区颜色 2:
1 | |
可迁移的做法是“稳定对象表 + 独立顺序表”。当对象本身有稳定身份,而显示、调度或优先级经常改变时,索引顺序比搬动整个对象更容易维持引用有效性。
点击同时更新焦点和遮挡
焦点决定键盘事件的接收者,Z 序决定显示和鼠标命中的优先级。这个演示采用常见的 click-to-focus 策略:左键从抬起变为按下时,先命中最上层窗口,再更新焦点并把它提升到 Z 序末尾。
1 | |
处理条件是按键边沿,不是“左键当前仍按着”。如果每个移动包都重复执行聚焦和置顶,拖动会制造无意义的状态变化,也会掩盖真正发生了几次焦点切换。
专项序列先点击 A,再点击 B。初始焦点已经是 B,因此两个动作恰好产生两次焦点变化和两次 Z 序变化。最终状态中的 changes=2/2 直接核对这两个计数,没有用最终截图推测中间发生过什么。
键盘路径只读取一个拥有者:
1 | |
A 获得焦点后输入 a,B 再次获得焦点后输入 b。GDB 最终读取两份独立缓冲为 a/1 和 b/1。共享键盘队列只产生一次字符事件,窗口管理器负责把它交给当时的唯一焦点。
拖动需要捕获,而不是连续命中
标题栏高度固定为 16 像素。左键在标题栏按下时,窗口管理器记录窗口编号和鼠标相对窗口左上角的偏移:
1 | |
捕获建立后,后续移动继续作用于同一个窗口。指针可以离开标题栏,也可以经过另一个窗口;拖动对象不会重新命中。新的位置由当前指针减去按下时的偏移得到,因此窗口不会在按下瞬间把左上角跳到指针位置。
1 | |
当前策略要求整个窗口留在屏幕内。A 的起点是 (20,40),按下点为 (40,45),捕获偏移是 (20,5);指针再移动 (30,20) 后到达 (70,65),所以 A 的新位置为 (50,60)。松开左键立即把 dragged 设为 -1,后续移动不再改变窗口。
拖动捕获是一种临时所有权。按下时选择接收者,释放前沿用这个选择,释放后清空。滚动条、滑块、框选和触摸手势也需要同样的状态,否则指针一离开最初的小区域,操作就会突然丢失。
遮挡恢复来自场景重放
窗口移走后,旧位置上存着的像素已经混入窗口、背景和光标。直接从旧显存复制无法判断每个像素原来属于谁。本篇保留完整场景状态,每次变化按固定顺序重建:
1 | |
对应代码先把整个离屏 surface 填成桌面色 3,再画两个完整窗口,最后画鼠标并 flush。A 从 (20,40) 移走后,测试直接读取旧左上角,得到背景色 3;新的重叠点 (120,90) 得到顶层 B 的客户区颜色 2:
1 | |
pixels=3/2 分别是旧位置和重叠位置的实测值。前者证明旧窗口痕迹消失,后者证明最终遮挡顺序正确。截图只提供整体画面,像素断言负责把“看起来对”收紧为可重复检查:
全量重绘也有明确成本:每次 flush 固定复制 64,000 字节。本次九条 HMP 命令产生 8 个鼠标包,连同字符变化共完成 10 次重绘。这是固定输入序列的计数,不是延迟或吞吐基准;没有测量结果支持之前,脏矩形只会增加一套新的区域合并和失效状态。
一条事件的完整路径
窗口代码没有再次读取 0x60。Day 27 的共享入口仍负责 IRQ1/IRQ12 分流,鼠标解析器输出归一化事件,窗口循环才改变 UI 状态:
1 | |
这个边界让硬件协议和窗口策略保持独立。PS/2 解析器只关心三字节包,窗口管理器只接收 dx、dy 和按钮状态;将来替换成其他指针设备时,窗口状态机不需要理解新的原始报文。
实现目前仍在一个内核演示循环中,没有创建独立 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 | |
解压后执行:
1 | |
附件包含第 01–28 篇累计工程和打包前形成的 docs/day28-* 证据,不含 build/。冻结后在新的空目录重建并复跑全部检查;day28-zipcheck.txt 是附件自身的复验收据,不写回 ZIP。
练习
- 在客户区按住左键后移动,确认焦点会变化但窗口不会跟随指针;再与标题栏拖动对照。
- 把 A 拖到四条屏幕边界,检查整个窗口仍在
[0,320) × [0,200)内,且鼠标偏移不跳变。 - 将 Z 序改成三个窗口,用同一份顺序分别完成绘制和反向命中,验证任意重叠点只交给最上层对象。
- 合并一批连续移动后再刷新,记录重绘次数与最终位置;不要只凭计数宣称性能改善。
上一篇:27 - 鼠标输入。
下一篇:29 - 窗口中的用户态 Shell。
参考资料
- QEMU 7.2 QAPI UI schema:
input-send-event与screendump的自动化入口。 - 26 - 字体与后备缓冲:离屏 surface、半开矩形与全帧复制边界。
- 27 - 鼠标输入:窗口层消费的鼠标事件来源。

