Skip to content

导引:点亮什么与为什么

到 030 为止,桌面有了一只会动的窗口骨架:窗口能拖、能关、有 Z 序。但这些窗口里面是空的——内容区一片浅色,什么都干不了。这一章要补上最关键的一刀:让窗口真正能"做事"。具体说,做一个原生终端窗口——你点中它、在里头打字,字符就带着光标出现在窗口里,还能往里灌 shell 输出,出彩色、能滚动。

可这一章不是把 030 那套画法再画一个矩形。它换了一种渲染模型:从「控件自己拿画布、当场画像素」(即时模式),换成「控件把自己想画的指令收集进一张清单,最后由合成器批量落屏」(保留模式)。前者是 029 那块 Canvas 的玩法;后者是这一章要立的 Widget 树 + PaintList。这一换,脏区重绘、批量合成、跨进程共享画面全都打开了。窗口、窗口管理器、终端,在这套模型下都只是 Widget 树上的节点。

这一章咱们要点亮什么

一件很直观的事:开一个 SDL 窗口,里头跑着一个真 shell;你点中窗口,键盘敲下去,字符带着反色光标出现在窗口里;ls --color 出来的目录清单是彩色的;写满一行自动换行,写满一屏向上滚。整条链路是:

text
你敲键盘 → SDL 把按键事件交给 host
        → host 把可打印字节写进 PTY master fd
        → shell 在 PTY slave 那头读、执行、把结果写回 PTY
        → host 每帧非阻塞 read PTY → TerminalWidget::write(bytes)
        → write 逐字节 put_char_:解析 \n/\r/\b/\t + ANSI SGR/光标/清屏 → 改 cells_ + 标脏
        → Desktop::render 调 root->flatten(PaintList) 把整棵树拍成绘制清单
        → Compositor::render 逐条 cmd 画进 staging Surface(只重画脏区)
        → host 把 staging 推上屏

这条链路背后,点亮了四样 030 里还不存在的东西。

第一样是 Widget 基类。030 的窗口是个普通类,窗口管理器存着一串窗口指针。可这一章要让"窗口里能放不同的内容"——终端是一种内容,以后还会有按钮、文本框、滑块。如果每种内容都得窗口管理器亲自认识,那每加一种控件就要回来改管理器,耦合死。正解是让所有能画、能命中、能收事件的东西共享一个虚接口 Widget:它持一个矩形、一串子控件、三个虚 hook——paint_to_list(画自己)、hit_test(点中谁)、on_pointer/on_key(收事件)。窗口管理器只跟 Widget* 打交道,具体是什么控件由虚派发决定。

第二样是 PaintList 保留模式。029 的 Canvas 是即时模式:你调一次 draw_rect,它当下就把像素写进帧缓冲。这一章换成保留模式:控件不直接画像素,而是把"我想画一个 8×16 的红色矩形""我想在 (x,y) 画一个字符 'A'"这类绘制指令塞进一张 PaintList;一帧结束时,合成器 Compositor::render 遍历这张清单、逐条落屏。这听像是多此一举,可它带来三个即时模式给不了的东西:脏区重绘(只重画变化了的矩形)、批量合成(一帧的指令一次性走完,可以裁剪/排序)、跨进程共享(这张清单是纯数据,可以序列化丢给另一个进程的合成器——这是 087 把 GUI host 搬到用户态的地基)。

第三样是 TerminalWidget。这是第一个"跑在窗口里的应用"。它继承 Widget,自带一张 cols × rows 的字符网格、光标、ANSI 转义状态机、一套 256 色调色板。它的 paint_to_list 不画像素,而是往清单里塞 fill_rect(铺底色 / 非 default 的 cell 背景)+ text_glyph(每个非空 cell 的字符)。键盘不进 on_key——host 直接把字节写进 PTY,shell 的回显经 PTY 绕回来再 write 进控件。这条"输入不经控件、输出才进控件"的分工,是终端和普通输入控件最本质的区别。

第四样是 Window 和 WindowManager 也都是 Widget。Window 是复合控件:它自己画标题栏 + 圆角 body,还持一个 content_ 子 widget(终端就挂这儿);WindowManager 是桌面根,自己管一串 windows_[](不用 Widget 框架的 children_,原因后面讲)。整棵树就是 WindowManager → Window → TerminalWidget,一帧一次 flatten 把它拍平成一张 PaintList。

还有一条贯穿全章的暗线:TerminalWidget 把"输入字节 → 改 cells_"和"重画"完全解耦。write() 改完 cells_ 只标脏(dirty_rows_[row]=true),不主动画;真正画是 Desktop::render 在帧边界统一做。这就是保留模式的精髓——状态变更和绘制分离,控件只负责"我现在长什么样",合成器负责"什么时候、画哪块、怎么画"。

为什么现在需要它

回顾 030 给咱们留下的家底:窗口有标题栏、能命中测试、能被拖动;窗口管理器有 Z 序、有从顶向下的命中。事件那边,鼠标和键盘已经能进事件队列。可这些家底是建立在 029 那块 Canvas 上的——每个窗口自己持一块离屏画布,draw_rect/draw_text 当场写像素,合成器把各窗口的画布 blit 到屏幕。这套在"窗口里只画静态背景色"时没问题,可一旦窗口里要画"会变化的内容"(终端、按钮、文本框),三个硬伤就露出来了:

  • 没有脏区。终端敲一个字符,严格说只需要重画那一格;可即时模式下你得把整块离屏画布重画一遍再 blit,90% 的像素是白画的。
  • 没有跨进程边界Canvas 直接写帧缓冲物理地址,这是内核态才能干的活。以后想把 GUI host 搬到用户态(087 那条 /dev/fb0 mmap 路径),控件层就不能再直接碰像素——它得产出一份"我想画什么"的描述,让另一边的合成器去落屏。
  • 没有组合语义。"窗口里套一个终端、终端里再套一个光标块"这种层级,即时模式下每个东西都得自己算坐标偏移、自己裁剪超出父矩形的像素。一旦层级深了,坐标算错和越界画就是家常便饭。

保留模式三样都解:PaintList 是纯数据(fill_rect / text_glyph / clip_push 这些 cmd),脏区可以单独收集(只重画标了脏的控件矩形),跨进程只需要把这张清单搬过边界(087 的 staging buffer 就是干这个的),层级裁剪由 flatten 的 clip 栈保证(控件画不出祖先矩形外)。

至于"窗口里画什么",030 的内容区是抹一层背景色了事。这一章的 TerminalWidget 要 override 掉内容绘制,自己往清单里塞字符。所以这一章补的是应用层——在 030 的窗口骨架之上,长出第一个有自己的状态、自己的绘制逻辑、能响应输入的"东西",并且顺手把整套渲染模型换成保留模式。

035_multi_terminal-45-gf25de18 · f25de18 · 2026-08-04