//! 绘制 Console 输入、结果历史和对应命中区域。 //! 这里消费工具状态并生成显示列表;用户程序的编译执行由提交路径和 worker 完成。 //! use super::*; /// 将有界 HTTP 活动、当前编辑输入和输入法预编辑投影为 Console 显示行与命中布局,并按需滚动到光标。 // developer_tools::build 在 Console 标签选中时调用;records 是父入口克隆的活动快照,后续绘制不持活动日志锁。 pub(super) fn build( p: &mut Painter, state: &mut DeveloperToolsState, records: &[Exchange], viewport: Rect, ) { // x 是工具画布横向原点,w 为可用宽度;本页标题及行区纵坐标沿用父工具布局约定。 let x = viewport.x; let w = viewport.width; // 此工具页使用其既定标题栏/底栏布局坐标,输出区与输入区共享下方 transcript 滚动面板。 let bottom = viewport.height - 22.0; p.fill(Rect::new(x, 32.0, w, 30.0), BAR); // Clear 的可用性与下面输出记录过滤条件一致,仅存在清屏水位之后的可展示记录才启用。 p.enabled_button( Rect::new(x + 3.0, 32.0, 62.0, 29.0), "Clear", Action::ClearConsole, records.iter().any(|r| { r.id > state.console_after && matches!(r.method.as_str(), "PUT" | "POST" | "DELETE") && !r.path.ends_with("/events") }), ); p.button( Rect::new(x + 68.0, 32.0, 106.0, 29.0), // 按钮文本反映当前跟随设置,点击 Action::Follow 后由状态处理器切换,不在绘制时直接改 follow。 if state.follow { "Follow: on" } else { "Follow: off" }, Action::Follow, state.follow, ); p.button( Rect::new(x + 181.0, 32.0, 119.0, 29.0), "Reset session", Action::ResetSession, false, ); // 窄窗口省略辅助会话说明,保留清屏、跟随和重置按钮,不改变真实会话状态。 if w > 520.0 { p.label(x + 309.0, 39.0, "app.main · live objects", MUTED, false); } // 先把活动记录投影成显示行,再把当前输入附在末尾;不会在绘制函数中提交或执行程序。 let mut lines = Vec::new(); // console_after 是清屏水位,只展示其后会改变程序状态的请求;原生 /events 输入不刷入 Console。 for r in records.iter().filter(|r| { r.id > state.console_after && (r.method == "PUT" || r.method == "POST" || r.method == "DELETE") && !r.path.ends_with("/events") }) { // 按路径及方法给观察记录选择显示标签,分类不执行 API,也不决定 worker 的真实请求处理。 let kind = if r.path.ends_with("/repl") { // 同一 /repl 的 DELETE 显示会话重置,其他方法显示持续会话执行,区别于独立 /evaluate。 if r.method == "DELETE" { "RESET SESSION" } else { "REPL · live objects" } } else if r.path.ends_with("/evaluate") { "ISOLATED API EVALUATION" } else if r.method == "PUT" { "FULL SOURCE · live program" } else { "CLEAR DOCUMENT" }; // Console 自己发起的 REPL 请求省略冗余标题和 HTTP 状态行,外部请求保留来源与诊断入口。 // 只简化本工具发出的 POST /repl;重置和外部请求仍保留来源与状态,避免混淆不同操作。 let local = r.origin == "Console" && r.path.ends_with("/repl") && r.method == "POST"; // 非本地简化输出加可点击标题,Action::Request 根据稳定请求 ID 打开 Network 详情。 if !local { let mut title = Line::new( format!("#{} {} {} {}", r.id, r.timestamp, r.origin, kind), MUTED, ); // 强调分隔线标记一次执行起点,标题动作与文本一起交给 Painter 注册命中目标。 title.divider = Some(Divider::Accent); title.action = Some(Action::Request(r.id)); lines.push(title); } // 请求正文逐行展示,首行 > 提示符、续行两个空格;保留源码换行而不执行文本。 for (i, line) in r.request.split('\n').enumerate() { lines.push(Line::new( format!("{}{line}", if i == 0 { "> " } else { " " }), TEXT, )); } // 颜色只按已记录 HTTP 状态判定,pending 尚无失败码时暂用成功色变量,但实际分支显示灰色 Executing。 let color = if r.status.is_some_and(|s| s >= 400) { ERROR } else { OK }; // 未完成请求显示 Executing,完成后才解析响应;HTTP 4xx/5xx 使用错误颜色。 if let Some(status) = r.status { if !local { lines.push(Line::new( format!("‹ {status} · {} ms", r.elapsed_ms), color, )); } // 响应格式化由 painter 模块解析已记录正文;预览截断导致 JSON 无法解析时会回退原文显示。 console_result(&mut lines, &r.response, color); } else { lines.push(Line::new(" Executing…", MUTED)); } // 每条请求结束添加中性分隔线,包括仍执行中的记录,让后续输入与相邻历史有明确边界。 let mut separator = Line::new("", MUTED); separator.divider = Some(Divider::Neutral); lines.push(separator); } // 没有可展示历史时补欢迎说明,这些行也属于 transcript 输出,后面的输入起始偏移包含它们。 if lines.is_empty() { multiline( &mut lines, "Endor · live session\nCreate objects here; call page.render(...) to display.\n", MUTED, ); } use std::hash::{Hash, Hasher}; // 只对输出行文本求 stamp,用来判断 Follow 是否需要跳到底部;当前编辑输入不参与输出变化检测。 let mut hash = std::collections::hash_map::DefaultHasher::new(); // 只散列 value,不把选择、颜色或分隔线样式变化当成新输出,避免无关重绘触发跟随到底部。 for line in &lines { line.value.hash(&mut hash); } let stamp = hash.finish(); // 保存本次指纹作下一帧比较,Follow 开关只决定是否采用变化信号,不影响指纹更新。 let output_changed = stamp != state.console_stamp; state.console_stamp = stamp; // 记录输入开始的行号,后续光标和点击区域都需加上 transcript 输出行偏移。 let first_input = lines.len(); // 输出历史与可编辑输入共用一个 Transcript 区域,底部留出状态提示和滚动条空间。 let area = Rect::new(x + 4.0, 63.0, w - 5.0, (bottom - 88.0).max(1.0)); // 输入法 preedit 只插入显示副本,不提交到实际 input;只有输入框持焦点时展示组合文本。 let mut input = state.input.clone(); // 只在输入框持焦点时把 preedit 插到显示副本,编辑字符串本身仍等最终 IME Commit。 if state.focus == Some(Focus::Input) { input.insert_str(state.cursor, &state.preedit); } // 显示输入保留换行并加提示符;全选高亮只跟随 Input 焦点,不把历史输出当成可替换输入。 for (i, line) in input.split('\n').enumerate() { let mut line = Line::new(format!("{}{line}", if i == 0 { "> " } else { " " }), TEXT); line.selected = state.selection && state.focus == Some(Focus::Input); lines.push(line); } // 提示符用真实等宽字体度量,后续光标和鼠标定位共享同一文本起点。 let prefix = text::measure("> ", TextStyle::code()).width; // cursor 是输入字符串的字节边界,由编辑逻辑维护;通过换行计数定位行,再用真实字体宽度计算列位置。 let before = &state.input[..state.cursor]; // cursor 以原输入的 UTF-8 字节位置计,按其之前换行数求行号,最后一段就是当前行已有正文。 let row = before.matches('\n').count() as f32; let column = before.rsplit('\n').next().unwrap_or(""); // 列正文与 preedit 一起测量,光标画在组合输入后;提示符宽度另加而不参与待编辑字符串。 let caret_x = prefix + text::measure(&format!("{column}{}", state.preedit), TextStyle::code()).width; // 当前输入行加上输出行数得到整个 transcript 中的纵向位置,滚动量稍后统一扣除。 let caret_y = (first_input as f32 + row) * ROW; // 新输出触发 Follow,但显式 reveal_input 优先,避免执行后或编辑时光标被自动滚动隐藏。 let follow = state.follow && output_changed && !state.reveal_input; // 把现有滚动量钳入“光标可见”的允许范围,而非每次将光标强制居中;横向预留 24 单位空间。 if state.reveal_input { // 编辑触发的 reveal 优先保住光标,不跳到历史尾部;校正后清标记,只消费一次请求。 let s = &mut state.scroll[Region::Transcript as usize]; s.y = s.y.clamp( (caret_y + ROW + 16.0 - area.height).max(0.0).min(caret_y), caret_y, ); s.x = s.x.clamp((caret_x + 24.0 - area.width).max(0.0), caret_x); state.reveal_input = false; } // Painter 测量完整内容并钳制最终滚动,必须在此之后再读取 scroll 计算输入命中布局。 p.lines(area, Region::Transcript, &lines, state, follow); let scroll = state.scroll[Region::Transcript as usize]; // 保存输入正文第一行的实际屏幕原点,包含内容边距、提示符、输出高度及最终两轴滚动。 let origin = Point::new( area.x + 8.0 + prefix - scroll.x, area.y + 5.0 + first_input as f32 * ROW - scroll.y, ); // 保存最终滚动后的输入原点与视口,供后续鼠标定位复用,不能仅凭字符数估算点击位置。 p.layout.input = Some(ConsoleInputLayout { viewport: area, origin, }); // 输入可见竖向片段与 transcript 相交,下面只为交集注册 Focus(Input),不覆盖历史文本区域。 let input_top = origin.y.max(area.y); let input_bottom = (origin.y + state.input.split('\n').count() as f32 * ROW).min(area.y + area.height - 9.0); // 仅为仍可见的输入片段注册文本输入命中区,扣除滚动条空间,避免历史输出区误抢输入焦点。 // 整个输入已滚出视口时不登记输入矩形,避免不可见编辑区域拦截鼠标。 if input_bottom > input_top { p.ui.inline_input( Rect::new( area.x, input_top, (area.width - 9.0).max(0.0), input_bottom - input_top, ), Action::Focus(Focus::Input), ); } // 光标单独绘制并限制在文本视口内,不能穿过滚动条或工具页边缘;随后配对 PopClip。 // 光标只是绘制覆盖层,先压入扣除滚动条的 clip,再画一像素竖线并弹出,不扩大输入命中范围。 if state.focus == Some(Focus::Input) { p.ui.list.push(DrawCommand::PushClip(Rect::new( area.x, area.y, (area.width - 9.0).max(0.0), (area.height - 9.0).max(0.0), ))); p.fill( Rect::new( area.x + 8.0 + caret_x - scroll.x, origin.y + row * ROW + 1.0, 1.0, ROW - 2.0, ), TEXT, ); p.ui.list.push(DrawCommand::PopClip); } // 底部无输入错误时展示快捷键,有错则复用同一位置显示错误文本及错误颜色。 p.label( x + 10.0, bottom - 23.0, if state.input_error.is_empty() { "Enter: run · Shift+Enter: newline · ⌘/Ctrl+↑↓: history" } else { &state.input_error }, if state.input_error.is_empty() { MUTED } else { ERROR }, false, ); }