//! 绘制 Console 输入、结果历史和对应命中区域。 //! 这里消费工具状态并生成显示列表;用户程序的编译执行由提交路径和 worker 完成。 //! use super::*; /// 将有界 HTTP 活动、当前编辑输入和输入法预编辑投影为 Console 显示行与命中布局,并按需滚动到光标。 pub(super) fn build( p: &mut Painter, state: &mut DeveloperToolsState, records: &[Exchange], viewport: Rect, ) { 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); 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), 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") }) { let kind = if r.path.ends_with("/repl") { 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 状态行,外部请求保留来源与诊断入口。 let local = r.origin == "Console" && r.path.ends_with("/repl") && r.method == "POST"; if !local { let mut title = Line::new( format!("#{} {} {} {}", r.id, r.timestamp, r.origin, kind), MUTED, ); 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, )); } 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, )); } 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); } 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(); for line in &lines { line.value.hash(&mut hash); } let stamp = hash.finish(); let output_changed = stamp != state.console_stamp; state.console_stamp = stamp; // 记录输入开始的行号,后续光标和点击区域都需加上 transcript 输出行偏移。 let first_input = lines.len(); 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(); if state.focus == Some(Focus::Input) { input.insert_str(state.cursor, &state.preedit); } 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]; let row = before.matches('\n').count() as f32; let column = before.rsplit('\n').next().unwrap_or(""); let caret_x = prefix + text::measure(&format!("{column}{}", state.preedit), TextStyle::code()).width; 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 { 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; } 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, }); 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。 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, ); }