//! 开发工具画面的绘制辅助:文字行、按钮、分隔线和滚动条。 //! 绘制命令与可交互区域同步登记,保证屏幕上的管理控件能用同一几何命中。 //! use super::*; /// 开发工具的绘制助手,把布局结果写入同帧显示列表。 pub(super) struct Painter { pub(super) ui: Ui, pub(super) layout: DeveloperToolsLayout, } impl Painter { /// 向宿主显示列表追加填充矩形,不登记点击或滚动目标。 pub(super) fn fill(&mut self, r: Rect, c: Color) { self.ui.fill(r, c); } /// 绘制工具文字,code 选择等宽风格;不在此处截断文本或登记目标。 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 作为选中状态参与外观。 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)); } /// 创建带启用条件的按钮,禁用项仍可显示但不能激活动作。 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)); } /// 记录待在最终覆盖层绘制的分隔条及其拖动类别,此处不立即画线。 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)); } /// 测量并缓存完整行宽,钳制滚动,仅绘制可见行,同时登记区域、选择和两轴滚动条。 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 } }; 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 { 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; for (i, line) in lines.iter().enumerate().skip(first).take(count) { let y = area.y + 5.0 + i as f32 * ROW - s.y; 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, }, ); } let paint = Rect::new(area.x, y, area.width, ROW); 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); } 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); } /// 按内容/视口比例绘制单轴滚动手柄,并登记拖动到内容位移的换算倍率。 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); 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, pub(super) divider: Option, pub(super) selected: bool, 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;不直接绘制,也不返回布局范围。 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 结果投影为可滚动、可选择的文字画面。 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, )); } if value["session_reset"] == true { multiline( out, value["message"] .as_str() .or(value["error"].as_str()) .unwrap_or("Session reset"), color, ); return; } if let Some(result) = value["value"].as_str() { multiline(out, result, color); return; } if let Some(diagnostics) = value["diagnostics"].as_array() && !diagnostics.is_empty() { 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; } if let Some(error) = value["error"].as_str() { multiline(out, error, color); return; } if let Some(revision) = value["revision"].as_u64() { out.push(Line::new(format!("Committed revision {revision}"), color)); return; } } multiline(out, body, color); } /// 将代码层次展开为工具可绘制的行数据,保留节点与源范围关联。 pub(super) fn flatten<'a>(node: &'a CodeNode, depth: usize, out: &mut Vec<(usize, &'a CodeNode)>) { // 先序展开保持 AST 子节点顺序,depth 供显示缩进;借用原节点而不复制整棵树。 out.push((depth, node)); for child in &node.children { flatten(child, depth + 1, out); } } /// 绘制选中请求的头部、正文预览与响应信息,不重新执行该请求。 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)); 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 }, ); } }