//! 开发工具画面的绘制辅助:文字行、按钮、分隔线和滚动条。 //! 绘制命令与可交互区域同步登记,保证屏幕上的管理控件能用同一几何命中。 //! use super::*; /// 开发工具的绘制助手,把布局结果写入同帧显示列表。 pub(super) struct Painter { // 所有工具页共用宿主 Ui,保存显示命令和同帧 Widget 命中;不是业务 Endor 的对象树。 pub(super) ui: Ui, // 除 Widget 外还保留输入、源码、滚动条和分隔条几何,交回 DeveloperToolsState 处理后续事件。 pub(super) layout: DeveloperToolsLayout, } impl Painter { /// 向宿主显示列表追加填充矩形,不登记点击或滚动目标。 // console/network/settings/sources 画背景或高亮时调用,只委托底层 Ui 填充。 pub(super) fn fill(&mut self, r: Rect, c: Color) { self.ui.fill(r, c); } /// 绘制工具文字,code 选择等宽风格;不在此处截断文本或登记目标。 // 工具页标题、文本行和状态提示使用,text 的测量/字形绘制由显示列表路径统一完成。 pub(super) fn label(&mut self, x: f32, y: f32, value: &str, c: Color, code: bool) { self.ui.label(x, y, value, c, code); } /// 创建普通或切换按钮,active 作为选中状态参与外观。 // Console 和 Sources 的普通/切换按钮共用,Action 同时提供稳定控件身份及后续激活语义。 pub(super) fn button(&mut self, r: Rect, label: &str, action: Action, active: bool) { // Follow 与数据源切换使用 Toggle 语义,其余动作使用普通按钮角色;身份仍由 action 决定。 let role = match action { Action::Follow | Action::Snapshot | Action::Cloud => Role::Toggle, _ => Role::Button, }; self.ui .button(r, action, Button::new(label).selected(active).role(role)); } /// 创建带启用条件的按钮,禁用项仍可显示但不能激活动作。 // Console/Network 清屏按钮依据是否有记录启用;disabled 的交互规则由底层 Ui 统一处理。 pub(super) fn enabled_button(&mut self, r: Rect, label: &str, action: Action, enabled: bool) { self.ui .button(r, action, Button::new(label).enabled(enabled)); } /// 记录待在最终覆盖层绘制的分隔条及其拖动类别,此处不立即画线。 // Sources 与 Network 布局调用,父 build 最后遍历 splits 以覆盖在内容之上。 pub(super) fn split(&mut self, r: Rect, kind: Split) { // Paint and register splitters after all pane content, in one overlay pass. // 此处只记录分隔条布局,外层最后统一覆盖绘制/注册,保证线条与命中目标位于内容之上。 self.layout.splits.push((r, kind)); } /// 测量并缓存完整行宽,钳制滚动,仅绘制可见行,同时登记区域、选择和两轴滚动条。 // 各工具页共享行面板入口;Region 决定独立滚动/宽度缓存位置,Source 另外维护只读文字选择。 pub(super) fn lines( &mut self, area: Rect, region: Region, lines: &[Line], state: &mut DeveloperToolsState, follow: bool, ) { // 按固定行高估计完整内容高度并预留上下边距,不仅按当前可见行计算。 let content_h = lines.len() as f32 * ROW + 12.0; use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); // 散列完整行文本,滚动到屏幕外的行变化也会使整段最大宽度缓存失效。 for line in lines { line.value.hash(&mut hash); } // 宽度缓存只依赖行文本指纹,颜色/选择变化无需重新测量;文字样式在此固定为 code。 let fingerprint = hash.finish(); let cache = &mut state.widths[region as usize]; // 匹配指纹时复用宽度,否则重新测量所有行;颜色和行选择不影响这里的排版宽度。 let content_w = match *cache { Some((previous, width)) if previous == fingerprint => width, _ => { // 失配时测量所有行的最大宽度并加边距;后面虽然只画可见行,横向范围仍反映完整内容。 let width = lines .iter() .map(|line| text::measure(&line.value, TextStyle::code()).width + 20.0) .fold(0.0_f32, f32::max); *cache = Some((fingerprint, width)); width } }; // 按 Region 取出本面板状态,之后使用同一份钳制结果定位文本、选择和滚动手柄。 let s = &mut state.scroll[region as usize]; // Follow 跳到内容末端;否则只钳掉内容缩短后越界的旧偏移,保留用户当前阅读位置。 s.y = if follow { (content_h - area.height + 12.0).max(0.0) } else { s.y.clamp(0.0, (content_h - area.height + 12.0).max(0.0)) }; s.x = s.x.clamp(0.0, (content_w - area.width + 12.0).max(0.0)); // 裁剪扣掉右/下 9 单位滚动条空间,避免行文字覆盖滚动手柄。 self.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), ))); // 源码行前七字符为显示行号与间隔,选择数据去掉这段;输入法/Console 编辑使用其他状态。 if region == Region::Source { // 选择与复制按显示副本工作;此处固定去掉七字符前缀,并非根据 AST/source 字节范围回读原文。 let values = lines .iter() .map(|line| line.value.chars().skip(7).collect::()) .collect::>(); state.source_selection.set_lines(&values); self.layout.source = Some(( area, Point::new( area.x + 8.0 - s.x + text::measure(" ", TextStyle::code()).width, area.y + 5.0 - s.y, ), values, )); } // 按滚动量只遍历可见行,并多取两行覆盖边缘部分行;完整文本仍保留供测量和复制。 let first = (s.y / ROW).floor() as usize; let count = (area.height / ROW).ceil() as usize + 2; // i 保留完整文本的原始行号,不能把 skip 之后的可见序号当成选择或输入行号。 for (i, line) in lines.iter().enumerate().skip(first).take(count) { // 把完整行的 y 减去内容滚动,部分行也可进入裁剪;布局中仍保存其原行下标。 let y = area.y + 5.0 + i as f32 * ROW - s.y; // 行分隔使用主题强调色或中性色,与行文字共用当前 clip,不能盖到面板外。 if let Some(divider) = line.divider { self.fill( Rect::new(area.x, y, area.width, 1.0), match divider { Divider::Accent => self.ui.theme.accent, Divider::Neutral => BORDER, }, ); } // paint 用完整行区域绘制背景,后面的交互区域另按可见上下界收窄。 let paint = Rect::new(area.x, y, area.width, ROW); // 有动作的行登记可点击 row 并传递选中态;无动作但 selected 的行只画静态高亮。 if let Some(action) = &line.action { let top = y.max(area.y); let bottom = (y + ROW).min(area.y + area.height - 9.0); // 部分可见行只注册可见矩形,避免裁剪虽隐藏图形、交互区域却仍延伸到面板之外。 if bottom > top { self.ui.row( paint, Rect::new(area.x, top, area.width - 9.0, bottom - top), action.clone(), line.selected, ); } } else if line.selected { self.fill(paint, self.ui.theme.soft()); } // 源码字素选区单独叠加高亮;行选择与文字选择共存,位置由去掉行号后的 origin 计算。 if region == Region::Source && let Some((_, origin, values)) = &self.layout.source && let Some((left, right)) = state.source_selection.span( i, state.selection && state.focus == Some(Focus::Source), values, ) { self.fill( Rect::new(origin.x + left, y, right - left, ROW), self.ui.theme.hover(), ); } self.label(area.x + 8.0 - s.x, y, &line.value, line.color, true); } // 行内容绘制结束必须恢复外层 clip,再在外层画滚动条,否则会被刚扣除的手柄区域裁掉。 self.ui.list.push(DrawCommand::PopClip); // 记录完整内容范围给滚轮/拖动逻辑,再在裁剪弹出后绘制两轴滚动条,避免手柄被内容裁剪掉。 self.layout .regions .push((area, region, content_w, content_h)); self.scrollbar(area, region, false, s.y, content_h); self.scrollbar(area, region, true, s.x, content_w); } /// 按内容/视口比例绘制单轴滚动手柄,并登记拖动到内容位移的换算倍率。 // lines 对横纵轴各调用一次,拖动倍率连同手柄几何存入 layout.thumbs,供 pointer_moved 反算内容偏移。 pub(super) fn scrollbar( &mut self, r: Rect, region: Region, h: bool, offset: f32, content: f32, ) { // h 统一复用横纵轴公式;先扣除另一轴手柄占用,内容不溢出或视口无面积时不创建滚动条。 let viewport = if h { r.width } else { r.height } - 10.0; // 无溢出或无有效轨道时跳过,不登记无法移动的虚假手柄。 if content <= viewport || viewport <= 0.0 { return; } // 手柄按 viewport/content 比例缩放,至少 24 且不大于轨道;travel 和 maximum 至少 1 防止除零。 let length = (viewport * viewport / content).max(24.0).min(viewport); let travel = (viewport - length).max(1.0); let maximum = (content - viewport).max(1.0); // 内容偏移归一化后映射到手柄可移动距离;两者并非一比一像素关系。 let position = (offset / maximum).clamp(0.0, 1.0) * travel; // 横条在面板底部,竖条在右侧,轴选择只影响几何摆放,内容换算使用同一套比例。 let track = if h { Rect::new(r.x, r.y + r.height - 8.0, viewport, 8.0) } else { Rect::new(r.x + r.width - 8.0, r.y, 8.0, viewport) }; self.fill(track, BAR); // 手柄沿轨道移动,另外一轴保留一单位内缩,视觉大小与命中矩形采用同一 thumb。 let thumb = if h { Rect::new(track.x + position, track.y + 1.0, length, 6.0) } else { Rect::new(track.x + 1.0, track.y + position, 6.0, length) }; self.ui.scrollbar( thumb, WidgetId::new(("scroll", region, h)), if h { Axis::Horizontal } else { Axis::Vertical }, ); self.layout .thumbs // 保存每单位手柄拖动对应的内容位移倍率,事件处理可直接反算滚动量。 .push((thumb, region, h, maximum / travel)); } } #[derive(Clone)] /// 一行可绘制、可关联源码位置的工具文本。 pub(super) struct Line { pub(super) value: String, pub(super) color: Color, // 可选执行分隔线,与文字颜色分离;Console 请求标题和详情分组使用它。 pub(super) divider: Option, pub(super) selected: bool, // Some 使该行可激活请求/节点等动作,None 仅展示;selected 本身不赋予点击行为。 pub(super) action: Option, } #[derive(Clone, Copy)] /// 文字行分隔线的配色类别,区别执行强调线与普通中性线。 pub(super) enum Divider { Accent, Neutral, } impl Line { /// 创建默认无分隔线、未选中且无点击动作的显示行。 pub(super) fn new(value: impl Into, color: Color) -> Self { Self { value: value.into(), color, divider: None, selected: false, action: None, } } } /// 将文本按换行分成显示行并展开 Tab,追加到 out;不直接绘制,也不返回布局范围。 // console_result、request_details 及工具空态共用,把展示文本追加为行而不提交任何程序输入。 pub(super) fn multiline(out: &mut Vec, value: &str, color: Color) { out.extend( value .replace('\t', " ") .split('\n') .map(|s| Line::new(s, color)), ); } /// 把一条 Console 响应解析成显示行,供 transcript 面板统一滚动展示。 // Console 遍历已完成 Exchange 时调用,按响应字段优先级选一种主要结果,成功执行不等于页面重新渲染。 pub(super) fn console_result(out: &mut Vec, body: &str, color: Color) { // 响应若为 JSON,按 reset、value、diagnostics、error、revision 优先提取可读结果;不满足时退回原文。 if let Ok(value) = serde_json::from_str::(body) { // 先显示 REPL 是否发布新页面或仅保留内存状态,再显示实际结果/诊断,避免把成功执行等同于重绘。 if value["execution"] == "repl" { out.push(Line::new( if value["rendered"] == true { format!( "Rendered revision {} · session {}", value["revision"], value["session"] ) } else { format!( "Session {} · retained in memory; page unchanged", value["session"] ) }, MUTED, )); } // 会话重置消息优先于普通 value/diagnostics,显示后立即返回,避免重复呈现同一响应的其他字段。 if value["session_reset"] == true { multiline( out, value["message"] .as_str() .or(value["error"].as_str()) .unwrap_or("Session reset"), color, ); return; } // 只有字符串 value 被当作结果文本,其他 JSON 值类型继续尝试诊断、错误或版本分支。 if let Some(result) = value["value"].as_str() { multiline(out, result, color); return; } if let Some(diagnostics) = value["diagnostics"].as_array() && !diagnostics.is_empty() { // 每条诊断可选显示位置,再显示 message;缺 message 用固定失败提示,不调用源对象格式化。 for d in diagnostics { if let Some(line) = d["line"].as_u64() { out.push(Line::new( format!("Error · line {line}, column {}", d["column"]), color, )); } multiline( out, d["message"].as_str().unwrap_or("Execution failed"), color, ); } return; } // 没有更高优先级结果/诊断时显示 error 文本,同样直接结束本次主要结果提取。 if let Some(error) = value["error"].as_str() { multiline(out, error, color); return; } // 只有可转为 u64 的 revision 才生成提交提示;不符合已知字段形状的 JSON 仍会退回正文。 if let Some(revision) = value["revision"].as_u64() { out.push(Line::new(format!("Committed revision {revision}"), color)); return; } } // 非 JSON、被预览截断的 JSON 或无已知结果字段都按原预览显示,不因此丢失诊断内容。 multiline(out, body, color); } /// 将代码层次展开为工具可绘制的行数据,保留节点与源范围关联。 // Sources 在构造 AST 树行时调用,返回带深度的借用节点序列,节点 ID 和源位置由原树提供。 pub(super) fn flatten<'a>(node: &'a CodeNode, depth: usize, out: &mut Vec<(usize, &'a CodeNode)>) { // 先序展开保持 AST 子节点顺序,depth 供显示缩进;借用原节点而不复制整棵树。 out.push((depth, node)); // 按子节点原顺序递归,使显示树次序与发布 AST 一致;只增加显示深度,不修改节点。 for child in &node.children { flatten(child, depth + 1, out); } } /// 绘制选中请求的头部、正文预览与响应信息,不重新执行该请求。 // Network 为当前选中 Exchange 构造详情行,只有记录中已有的受限头部和正文能显示。 pub(super) fn request_details(out: &mut Vec, r: &Exchange) { multiline( out, &format!( "#{} {} {}\n{} · {} · {} ms\nStatus: {}", r.id, r.method, r.path, r.timestamp, r.origin, r.elapsed_ms, r.status.map_or("pending".into(), |s| s.to_string()) ), TEXT, ); // 固定次序展示请求头、请求正文、响应头、响应正文;这里只消费已经截断的观察记录。 for (title, body) in [ ("REQUEST HEADERS", &r.request_headers), ("REQUEST BODY", &r.request), ("RESPONSE HEADERS", &r.response_headers), ("RESPONSE BODY", &r.response), ] { out.push(Line::new("", MUTED)); // 分组标题用强调分隔线,正文沿原换行展开;空字段以 (empty) 明示,并非继续发起网络读取。 let mut l = Line::new(title, MUTED); l.divider = Some(Divider::Accent); out.push(l); multiline( out, if body.is_empty() { "(empty)" } else { body }, if title == "RESPONSE BODY" && r.status.is_some_and(|s| s >= 400) { ERROR } else { TEXT }, ); } }