Lab 031 · 原生终端应用:让窗口能打字
这个实验对应主书 031 · 原生终端应用。我们不在 lab 里贴完整答案代码——你要自己把"一个能打字的窗口"一层层搭起来。这里只给目标、约束、验证手段和排错方向。
实验目标
在 030 的"空窗口"基础上,做出内核里第一个跑在窗口里的应用:一个 80×25 的终端窗口。开机进 GUI,点中它拿到焦点,敲键盘,字符带着光标出现在窗口里,写满一行自动换行、写满一屏向上滚动。这一章只做本地回显(你敲什么画什么);让它真正跑 shell,是 Lab 031b 的事。
具体要点亮四件事:Window 变成多态基类、键盘事件第一次被窗口消费、Terminal 这个控件本身、以及每帧把它光栅化上屏。
前置条件
- 跑通 030:
Window/WindowManager/Canvas/EventQueue/ PS/2 鼠标 / PIT 滴答里的gui_tick_callback。 - 跑通 014:PS/2 键盘,键盘事件已经能经
irq1_handler的双路分发塞进Mouse::event_queue()(这条队列名字带 Mouse,实质是全局 GUI 事件队列)。 - 029 的
Canvas离屏构造init(w, h)(窗口双缓冲要用)。
任务分解
按依赖顺序,分七块做。
任务 1:让 Window 变成多态基类
改 kernel/gui/window.hpp 的 Window。
- 给它虚析构
virtual ~Window() = default;,否则以后通过基类指针 delete 派生类会泄漏。 - 加两个虚函数,基类给空实现(吃掉参数):
virtual void on_key(KeyEvent& ev) { (void)ev; }和virtual void on_paint(cinux::drivers::Canvas& canvas) { (void)canvas; }。 - 想清楚为什么必须虚化:窗口管理器手里只有
Window*,它要在不认识 Terminal 这个具体类型的前提下把键盘事件派发下去,靠的就是虚函数表。没有这套虚机制,WM 就得switch判断窗口类型,每加一种窗口都得回来改 WM。
任务 2:WindowManager 路由键盘 + 接收外部窗口
改 kernel/gui/window_manager.{hpp,cpp}。
handle_key实化:030 它是空桩((void)ev;),现在写成if (focused_ != nullptr) focused_->on_key(ev.key);。注意它不做命中测试——键盘只给focused_,鼠标才hit_test。ev.key是Event联合里的KeyEvent,按引用透传。- 新增
add_window(Window* win):接收外部已经new好的窗口(所有权转移),区别于内部自己new Window的create()。为什么需要它?因为Terminal是Window的派生类,WM 没法替你new一个派生类。add_window的后半段(画标题栏、画内容、count_++、update_focus)和create共用。 - 想清楚
focused_怎么来:update_focus()把最顶(windows_[count_-1])窗口设成焦点。raise/add_window/destroy都会调它重算。
任务 3:Terminal 控件骨架
新建 kernel/gui/terminal.{hpp,cpp}(#ifdef CINUX_GUI 守卫),命名空间 cinux::gui。
TerminalCell:{ char ch=' '; uint32_t fg=0x00FFFFFF; uint32_t bg=0x00000000; }(默认白字黑底空格)。Terminal : public Window:常量COLS=80、ROWS=25;私有成员TerminalCell screen_[ROWS][COLS]、cursor_x_、cursor_y_、fg_、bg_、cursor_visible_、font_(PSFFont*)、以及两个先留空的管道指针stdin_pipe_/stdout_pipe_。- 构造:
Terminal(uint32_t x, uint32_t y, const char* title),把基类 Window 初始化为title + (x,y) + COLS*8 宽 + ROWS*16 高,再把screen_全部置TerminalCell{}。窗口像素尺寸 = 640×400,因为 PSF 字体每字符 8×16。 - 不可拷贝(
= delete)。
任务 4:字符怎么落屏
实现 write(const char* str, uint64_t len) 和它调用的几个 helper。write 逐字节分发:'\n'→newline()、'\r'→cursor_x_=0、'\b'→backspace()、'\t'→tab()、其余→process_char();ESC(\033)交给任务 5 的 handle_ansi。
process_char:只认可打印 ASCII(0x20..0x7E,其余直接丢);把字符写进screen_[cursor_y_][cursor_x_],cursor_x_++;到COLS就归零并newline()。newline:cursor_x_=0; cursor_y_++;若cursor_y_ >= ROWS,先把cursor_y_钳到ROWS-1再scroll_up()——顺序不能反,否则会访问screen_[ROWS][...]越界。scroll_up:把第 1..ROWS-1 行整体上移一行、顶行(第 0 行)被覆盖丢弃,最后一行清空。注意:本 lab 不做 scrollback,滚出屏幕的内容永久丢失,这是有意的简化。backspace:行内退一格;行首则退到上一行末尾。tab:跳到下一个 8 列制表位(cursor_x_/8+1)*8,到边界钳到COLS-1。
任务 5:极简 ANSI
实现 is_escape(char)(就是 ch == '\033')和 handle_ansi(const char* str, uint64_t len, uint64_t& pos)。
- 期望
ESC [(CSI);不是 CSI 就pos++跳过 ESC 返回。 - 跳过
ESC [后,循环收集数字(累加成单个param)和;,遇到字母(final byte)派发:'J'且param==2→clear();'H'→cursor_x_=0, cursor_y_=0;'K'→把当前行cursor_x_..COLS-1清空;'m'(SGR)→直接忽略;default→return。 pos用引用传出,write的循环靠它跳过整段转义序列。关键边界:只解析单个数值参数,'H'无条件归零(所以ESC[10;20H在本终端等价于ESC[H),'m'被明确忽略。别声称支持颜色或光标定位。
任务 6:render_to_canvas 光栅化
实现 render_to_canvas() 和 on_paint(Canvas&)(后者形参忽略,体内只 if (font_) render_to_canvas();)。
- 开头
if (font_ == nullptr) return;——这条守卫是"开窗空白"的命门,见常见故障。 - 取基类画布
auto& cvs = canvas();(注意不是on_paint传进来的那个形参),gw = font_->width()、gh = font_->height()。 - 遍历每个 cell:
px = col*gw、py = TITLE_BAR_HEIGHT + row*gh(内容必须画在标题栏下方,TITLE_BAR_HEIGHT是基类定的 20);先draw_rect(px,py,gw,gh,cell.bg),若cell.ch > ' '就取 glyph 逐位光栅化:(g[gr] >> (7-gc)) & 1为 1 则draw_pixel(px+gc, py+gr, cell.fg)。 - 光标用反色块:
cursor_visible_时,在光标格draw_rect(cx,cy,gw,gh, cc.fg)铺满,再用cc.bg重画该格字符——天然反相、透出底下字符。
任务 7:gui_start 点火 + tick 刷新
改 kernel/gui/gui_init.cpp。
gui_start()返回类型从void改成Terminal*:Mouse::init()→set_screen_bounds→ 算终端尺寸(640×400)并居中 →new Terminal(...)→term->set_font(g_font)(别漏)→WindowManager::instance().add_window(term)→PIT::set_tick_callback(gui_tick_callback, nullptr)→return term。gui_tick_callback:排空Mouse::event_queue()(鼠标事件→handle_mouse,键盘事件→handle_key)之后,对wm.focused()做static_cast<Terminal*>再调poll_output()(这章休眠,见边界)和render_to_canvas(),最后wm.composite()。- 边界提醒:那个
static_cast<Terminal*>(focused())没有类型检查,赌的是"当前前台一定是 Terminal"。本 lab 只有一个终端窗口,成立;别假装它对任意窗口类型都安全。
接口约束
- 窗口网格
COLS=80/ROWS=25,像素尺寸640×400;TerminalCell默认ch=' ',fg=0x00FFFFFF,bg=0x00000000。 on_key只处理ev.pressed && ev.ascii != 0;松开和功能键(ascii==0)直接 return。on_key的双分支语义(本 lab 只走第二支,但接口要写对):若stdin_pipe_非空,把ev.ascii('\r'转'\n')try_write进管道后立即 return、不本地回显;否则process_char(ev.ascii)。- ANSI 只接
ESC[2J/ESC[H/ESC[K/ESC[m四个 final byte;'m'忽略,'H'无条件归零,参数只解析单个数值。 render_to_canvas用基类canvas(),内容 Y 偏移TITLE_BAR_HEIGHT(=20)。
验证步骤
第一步:host 单元测试(test/unit/test_terminal.cpp 用 MockTerminal 镜像重画字符逻辑,不碰硬件):
ctest --test-dir build -R "terminal|window|canvas" --output-on-failure预期:Terminal 构造初始化空格屏、光标(0,0)、尺寸 640×400、write 的各种控制字符、backspace、tab、clear、on_key 只接可打印字符、ESC[2J/H/K、常量 COLS=80/ROWS=25 全过;window/canvas 验 on_key 虚派发与 blit 负坐标裁剪。
第二步:QEMU kernel 测试(真内核 Terminal,依赖 Framebuffer/PSFFont/heap):
cmake --build build --target run-big-kernel-test预期:run_terminal_tests() 与 run_window_manager_tests()(含新增的"on_key 经基类指针派发到子类"那组)通过。退出码约定:全过写 exit_code=0,经 isa-debug-exit 后 QEMU 退 1,脚本判 [ $QEMU_EXIT -eq 1 ]。
第三步:视觉效果:
cmake --build build --target run预期:开机进 GUI,屏幕中央一个 Cinux Terminal 窗口;鼠标点中它(置顶拿焦点),敲键盘,字符带反色光标出现在窗口里;写满一行换行、写满一屏滚动。这章只是本地回显——终端还不知道你在敲命令,这是正常的。
常见故障
- 窗口打开一片空白:九成是
font_没注入。render_to_canvas和on_paint都有font_ == nullptr守卫,字体指针靠set_font从外部注入,忘了调或g_font还是空,整段光栅化直接跳过。先查字体指针,别怀疑draw_text。 - 敲一个键出现两个字符:
on_key的本地回显分支和(将来的)管道回显分支同时走了。规矩是:回显永远只走一条路。本 lab 没挂管道,只该走process_char;挂了管道就必须try_write后return、绝不本地画。 - 滚动时越界 / 屏幕花一下:
newline()里先scroll_up后钳cursor_y_了,导致光标短暂指向screen_[ROWS][...]。必须先钳到ROWS-1再滚。 - ANSI 解析卡住或吞字符:
handle_ansi没把pos推进到序列末尾(write的循环就跳不出转义段),或没处理"ESC 后面不是["的情况(要pos++跳过这个孤立的 ESC)。 on_paint没效果:你在on_paint里用了它传进来的canvas形参。Terminal 画的是基类自己的canvas(),形参是被忽略的。set_pipe_write_fd/pipe_write_fd看着像按键通道,但接了没反应:这俩在本 tag 是 dead code(全仓无生产调用),历史遗留的"裸 fd"方案残骸。真实按键通道是stdin_pipe_->try_write,那是 Lab 031b 的事——本 lab 别去碰set_pipe_write_fd。
通过标准
- [ ] host
-R "terminal|window|canvas"全绿,test_host整体不回归。 - [ ]
run-big-kernel-test里run_terminal_tests()、run_window_manager_tests()通过。 - [ ]
Window有虚析构和on_key/on_paint虚函数(基类空实现);WindowManager::handle_key把事件透传给focused_->on_key。 - [ ]
Terminal继承Window,80×25 字符缓冲 + 光标,write正确处理\n\r\b\t与可打印字符,触底正确滚动,极简 ANSI(2J/H/K,忽略m)。 - [ ]
render_to_canvas用 PSF glyph 光栅化 + 反色光标块,内容画在标题栏下方;gui_start返回Terminal*并set_font。 - [ ] 在代码或报告里诚实标注两条边界:
static_cast<Terminal*>(focused)是硬编码假设(无 RTTI);set_pipe_write_fd/pipe_write_fd是 dead code,真实按键通道走stdin_pipe_->try_write(Lab 031b 点亮)。不把未接线的东西写成已工作。