From a0ff16e23fc3b945dbb634b8531d7dbe074c9aeb Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 19:15:28 +0800 Subject: [PATCH] docs: explain developer tool painting and source selection --- src/shell/developer_tools/painter.rs | 40 ++++++++++++++++++++++++-- src/shell/developer_tools/selection.rs | 25 ++++++++++++++++ src/shell/developer_tools/sources.rs | 15 ++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/shell/developer_tools/painter.rs b/src/shell/developer_tools/painter.rs index f9b6eaa..1f63ec0 100644 --- a/src/shell/developer_tools/painter.rs +++ b/src/shell/developer_tools/painter.rs @@ -5,19 +5,24 @@ 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 { @@ -28,17 +33,20 @@ impl Painter { .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, @@ -51,12 +59,14 @@ impl Painter { 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, _ => { @@ -69,6 +79,7 @@ impl Painter { width } }; + // 按 Region 取出本面板状态,之后使用同一份钳制结果定位文本、选择和滚动手柄。 let s = &mut state.scroll[region as usize]; // Follow 跳到内容末端;否则只钳掉内容缩短后越界的旧偏移,保留用户当前阅读位置。 s.y = if follow { @@ -86,6 +97,7 @@ impl Painter { ))); // 源码行前七字符为显示行号与间隔,选择数据去掉这段;输入法/Console 编辑使用其他状态。 if region == Region::Source { + // 选择与复制按显示副本工作;此处固定去掉七字符前缀,并非根据 AST/source 字节范围回读原文。 let values = lines .iter() .map(|line| line.value.chars().skip(7).collect::()) @@ -103,8 +115,11 @@ impl Painter { // 按滚动量只遍历可见行,并多取两行覆盖边缘部分行;完整文本仍保留供测量和复制。 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), @@ -114,7 +129,9 @@ impl Painter { }, ); } + // 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); @@ -146,6 +163,7 @@ impl Painter { } self.label(area.x + 8.0 - s.x, y, &line.value, line.color, true); } + // 行内容绘制结束必须恢复外层 clip,再在外层画滚动条,否则会被刚扣除的手柄区域裁掉。 self.ui.list.push(DrawCommand::PopClip); // 记录完整内容范围给滚轮/拖动逻辑,再在裁剪弹出后绘制两轴滚动条,避免手柄被内容裁剪掉。 self.layout @@ -155,6 +173,7 @@ impl Painter { self.scrollbar(area, region, true, s.x, content_w); } /// 按内容/视口比例绘制单轴滚动手柄,并登记拖动到内容位移的换算倍率。 + // lines 对横纵轴各调用一次,拖动倍率连同手柄几何存入 layout.thumbs,供 pointer_moved 反算内容偏移。 pub(super) fn scrollbar( &mut self, r: Rect, @@ -165,6 +184,7 @@ impl Painter { ) { // h 统一复用横纵轴公式;先扣除另一轴手柄占用,内容不溢出或视口无面积时不创建滚动条。 let viewport = if h { r.width } else { r.height } - 10.0; + // 无溢出或无有效轨道时跳过,不登记无法移动的虚假手柄。 if content <= viewport || viewport <= 0.0 { return; } @@ -174,12 +194,14 @@ impl Painter { 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 { @@ -201,12 +223,14 @@ impl Painter { 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, @@ -224,6 +248,7 @@ impl Line { } } /// 将文本按换行分成显示行并展开 Tab,追加到 out;不直接绘制,也不返回布局范围。 +// console_result、request_details 及工具空态共用,把展示文本追加为行而不提交任何程序输入。 pub(super) fn multiline(out: &mut Vec, value: &str, color: Color) { out.extend( value @@ -232,7 +257,8 @@ pub(super) fn multiline(out: &mut Vec, value: &str, color: Color) { .map(|s| Line::new(s, color)), ); } -/// 把一条 Console 结果投影为可滚动、可选择的文字画面。 +/// 把一条 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) { @@ -253,6 +279,7 @@ pub(super) fn console_result(out: &mut Vec, body: &str, color: Color) { MUTED, )); } + // 会话重置消息优先于普通 value/diagnostics,显示后立即返回,避免重复呈现同一响应的其他字段。 if value["session_reset"] == true { multiline( out, @@ -264,6 +291,7 @@ pub(super) fn console_result(out: &mut Vec, body: &str, color: Color) { ); return; } + // 只有字符串 value 被当作结果文本,其他 JSON 值类型继续尝试诊断、错误或版本分支。 if let Some(result) = value["value"].as_str() { multiline(out, result, color); return; @@ -271,6 +299,7 @@ pub(super) fn console_result(out: &mut Vec, body: &str, color: Color) { 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( @@ -286,27 +315,33 @@ pub(super) fn console_result(out: &mut Vec, body: &str, color: 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, @@ -330,6 +365,7 @@ pub(super) fn request_details(out: &mut Vec, r: &Exchange) { ("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); diff --git a/src/shell/developer_tools/selection.rs b/src/shell/developer_tools/selection.rs index 6007e83..35cac1f 100644 --- a/src/shell/developer_tools/selection.rs +++ b/src/shell/developer_tools/selection.rs @@ -11,14 +11,17 @@ use unicode_segmentation::UnicodeSegmentation; #[derive(Debug, Default)] /// 行文本、锚点与选择端点,既供鼠标拖选也供键盘导航使用。 pub(super) struct SourceSelection { + // Painter::lines 同步去行号的完整显示文本;只读 Sources 选择与 Console 的可编辑 input 分开维护。 lines: Vec, // 端点是 (行号, UTF-8 字节偏移),字节位置始终由完整字素边界产生,不用字符数直接切片。 anchor: (usize, usize), end: (usize, usize), + // DeveloperToolsState 的按下/移动/释放路径使用此标记,决定指针移动是否扩展选区并触发行滚动。 pub dragging: bool, } impl SourceSelection { /// 同步展示文本;内容变化时重置选择和拖动,内容完全相同时保留原选区。 + // Painter 在绘制 Source Region 时调用,传完整内容而非仅可见行,让滚出视口的选择仍可复制。 pub fn set_lines(&mut self, lines: &[String]) { // 文本变化时重置两端与拖选状态;当前实现不是把旧选区钳到新文本,避免对应内容已经改变。 if self.lines != lines { @@ -29,19 +32,24 @@ impl SourceSelection { } } /// 把工具指针位置换算成合法行号与文本字符边界。 + // begin/extend 共用坐标换算;origin 已包含当前滚动量和去行号偏移,不在此再次扣除滚动。 fn locate(p: Point, origin: Point, lines: &[String]) -> (usize, usize) { // 按固定行高求行号,向上下界钳制;空文本通过 saturating_sub 与后续 get 安全返回原点。 let row = (((p.y - origin.y) / super::ROW).floor().max(0.0) as usize) .min(lines.len().saturating_sub(1)); + // 空 lines 没有可切片文本,返回 (0,0) 与默认选择一致,避免继续访问无效行。 let Some(line) = lines.get(row) else { return (0, 0); }; + // 横向坐标相对正文原点并钳到非负,行号区或左侧留白点击按行首处理。 let x = (p.x - origin.x).max(0.0); + // 先假定落在行尾,扫描到第一个位于点击右侧的字素中点时,改取该字素起点。 let mut byte = line.len(); // 逐个字素测量左右边界,以中点决定插入位置,点击超过行尾则保留 byte=line.len()。 for (i, g) in line.grapheme_indices(true) { let left = text::measure(&line[..i], TextStyle::code()).width; let right = text::measure(&line[..i + g.len()], TextStyle::code()).width; + // 严格小于中点时选字素前边界;在中点或之后继续扫描,相当于倾向该字素右边界。 if x < (left + right) * 0.5 { byte = i; break; @@ -50,6 +58,7 @@ impl SourceSelection { (row, byte) } /// 在指针位置建立文本选择锚点,开始一次拖动选择。 + // DeveloperToolsState 在源码区按下时调用,折叠到新锚点并开启拖选;这里不修改文本行内容。 pub fn begin(&mut self, p: Point, origin: Point, lines: &[String]) { // 调用方提供的 lines 与当前展示一致;开始拖动先折叠旧选择,后续 extend 只移动活动端。 self.anchor = Self::locate(p, origin, lines); @@ -57,10 +66,12 @@ impl SourceSelection { self.dragging = true; } /// 保持原锚点,随指针更新选择另一端。 + // 源码拖选的 pointer_moved 调用;锚点固定,端点按当前显示快照重新定位,越界行由 locate 钳制。 pub fn extend(&mut self, p: Point, origin: Point, lines: &[String]) { self.end = Self::locate(p, origin, lines); } /// 用键盘移动文本选择端点,按扩展模式决定是否保留锚点。 + // DeveloperToolsState::key 在 Source 焦点下调用,返回活动端行号供外层调整滚动;非导航键返回 None。 pub fn navigate( &mut self, key: &winit::keyboard::NamedKey, @@ -68,11 +79,14 @@ impl SourceSelection { command: bool, ) -> Option { use winit::keyboard::NamedKey::*; + // 从活动端移动,不从已排序范围起点移动,因此 Shift 可以跨过原锚点形成反向选区。 let (mut row, mut byte) = self.end; + // 没有当前行时不导航;后面行数减一依赖这里已证明 lines 非空。 let line = self.lines.get(row)?; match key { // 水平移动按字素跨越,组合音标和 emoji 视作整体;到行边界后跨到相邻行的末尾/开头。 ArrowLeft => { + // 左移使用当前位置之前的最后一个字素起点,行内切片边界来自此前定位/导航的不变量。 if byte > 0 { byte = line[..byte] .grapheme_indices(true) @@ -84,6 +98,7 @@ impl SourceSelection { } } ArrowRight => { + // 右移取后缀第一个完整字素长度,行尾才跨到下一行开头,不将换行当作行内字节。 if byte < line.len() { byte += line[byte..].graphemes(true).next().map_or(0, str::len); } else if row + 1 < self.lines.len() { @@ -93,12 +108,15 @@ impl SourceSelection { } // 垂直移动保持的是字素列数,不是像素 x;目标行较短时取尽其字素,落到行尾。 ArrowUp | ArrowDown => { + // 记录当前前缀字素数量,在目标行最多消费同样数量,不能直接复用 UTF-8 字节偏移。 let col = line[..byte].graphemes(true).count(); + // 向上使用饱和减法,向下钳到最后一行;到文件边界继续按键仍停在有效行。 row = if *key == ArrowUp { row.saturating_sub(1) } else { (row + 1).min(self.lines.len() - 1) }; + // 目标行逐字素累加字节长度,较短行自然停在行尾,结果仍是安全切片边界。 byte = self.lines[row] .graphemes(true) .take(col) @@ -120,6 +138,7 @@ impl SourceSelection { } _ => return None, } + // 所有受支持导航完成后统一更新活动端,再按 Shift 决定保留或同步锚点。 self.end = (row, byte); // 未按 Shift 时同步锚点以折叠选区;按 Shift 则仅移动 end,保持原锚点扩展选择。 if !shift { @@ -128,7 +147,9 @@ impl SourceSelection { Some(row) } /// 返回排序后的有效选择范围,隐藏拖动方向差异。 + // text 与 span 共用范围归一化;all 是外层全选标记,不需要重写保存的 anchor/end。 fn range(&self, all: bool) -> ((usize, usize), (usize, usize)) { + // 全选从首行首字节到末行末字节,空内容通过饱和索引和缺省末行长度保持零范围。 if all { return ( (0, 0), @@ -142,7 +163,9 @@ impl SourceSelection { (self.anchor.min(self.end), self.anchor.max(self.end)) } /// 拼接当前选择范围内的实际文本,保持多行换行与 UTF-8 边界。 + // DeveloperToolsState::selected_text 在复制路径调用,返回自有字符串;不直接写系统剪贴板。 pub fn text(&self, all: bool) -> String { + // 先统一正向范围,随后选中行顺序按文档正序输出,拖选方向不改变复制文本次序。 let (a, b) = self.range(all); self.lines .iter() @@ -158,12 +181,14 @@ impl SourceSelection { .join("\n") } /// 取得某行的可见选择片段,供高亮绘制复用。 + // Painter::lines 给可见源码行调用,将保存的字节端点转成真实等宽字体的两个横向位置。 pub fn span(&self, row: usize, all: bool, lines: &[String]) -> Option<(f32, f32)> { let (a, b) = self.range(all); // 未选中的行或折叠选择不画高亮;有选择时返回两个真实字体测量的横向位置,不是宽度值。 if row < a.0 || row > b.0 || a == b { return None; } + // 使用本帧展示行测量,越界行直接 None;下面前缀 get 也会拒绝不一致字节端点。 let line = lines.get(row)?; let start = if row == a.0 { a.1 } else { 0 }; let end = if row == b.0 { b.1 } else { line.len() }; diff --git a/src/shell/developer_tools/sources.rs b/src/shell/developer_tools/sources.rs index f55f88d..35a1e1a 100644 --- a/src/shell/developer_tools/sources.rs +++ b/src/shell/developer_tools/sources.rs @@ -4,12 +4,14 @@ use super::*; /// 根据云文件/当前快照模式构建左右两栏,展示文件或 AST 选择,并生成只读源码行和跳转动作。 +// developer_tools::build 在 Sources 标签选中时调用,左侧生成可激活动作,右侧只读展示文本并供 SourceSelection 复制。 pub(super) fn build( p: &mut Painter, state: &mut DeveloperToolsState, doc: &RenderDocument, viewport: Rect, ) { + // 横向原点/宽度来自工具画布,AST 或文件树与源码面板都使用这个局部坐标系。 let x = viewport.x; let w = viewport.width; // width 在本函数表示右边界坐标,w 才是视口宽度;用于计算右侧源码区域和按钮位置。 @@ -17,6 +19,7 @@ pub(super) fn build( let bottom = viewport.height - 22.0; p.fill(Rect::new(x, 32.0, w, 30.0), BAR); + // 两个切换按钮仅发出 Snapshot/Cloud 动作,改变当前展示模式,不替换文档或执行文件。 p.button( Rect::new(x + 3.0, 32.0, 139.0, 29.0), "Current snapshot", @@ -39,8 +42,10 @@ pub(super) fn build( ); // 树宽至少 120,通常为右侧内容保留 180;窄窗口仍保持树最小宽,交由绘制裁剪处理。 let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0)); + // 树右边界后预留分隔线间隙,再作为源码区左边界;width 是工具右边界坐标而非可用宽度。 let code_x = x + tree + 2.0; p.label(x + 10.0, 69.0, "CLOUD FILES", MUTED, false); + // 列表来自最近成功 provider 刷新,每行只携带显示路径和不透明 ID;这里不会同步调用外部服务。 let file_lines = state .cloud_sources .files @@ -59,11 +64,14 @@ pub(super) fn build( state, false, ); + // 登记树/源码的竖向分隔线,真实覆盖绘制和命中由父入口最后完成。 p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree); + // 云文本与 provider 错误组合成本帧行列表,旧打开内容可与新错误同时展示。 let mut source_lines = Vec::new(); // 成功打开的云文本逐行加显示行号;这里不替换 doc.source,也不触发程序热重载。 if let Some(source) = &state.cloud_sources.opened { p.label(code_x + 10.0, 69.0, &source.path, MUTED, false); + // 显示加一基行号和两空格前缀,复制时 Painter 按约定剥除前七字符;原始 code 不被改写。 for (i, line) in source.code.split('\n').enumerate() { source_lines.push(Line::new(format!("{:>5} {}", i + 1, line), TEXT)); } @@ -75,6 +83,7 @@ pub(super) fn build( ); } // provider 错误附在当前源码下方;即使读取失败仍可展示上次成功打开的内容。 + // 错误作为追加行显示,不清掉文件列表或已打开文本;重绘不会重试失败请求。 if !state.cloud_sources.error.is_empty() { source_lines.push(Line::new(&state.cloud_sources.error, ERROR)); } @@ -86,6 +95,7 @@ pub(super) fn build( false, ); } else { + // 快照模式沿用同一个树宽状态,切换模式不另设隐藏的分隔条宽度。 let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0)); let code_x = x + tree + 2.0; p.label(x + 10.0, 69.0, "ASTER AST", MUTED, false); @@ -109,11 +119,15 @@ pub(super) fn build( MUTED, false, ); + // AST 行与源码行分开收集,Tree/Source 两个 Region 各自维护滚动和宽度缓存。 let mut tree_lines = Vec::new(); + // 只展开已发布文档中的 CodeNode 树,不从当前 Console 输入临时解析一棵新 AST。 if let Some(root) = &doc.root { + // flatten 保存深度及节点借用,不复制原树;显示文本和跳行动作在后面的循环构造。 let mut flat = Vec::new(); // 将发布 AST 展平成带深度的行;行动作保存节点稳定 ID 与起始行,用于选择并跳到对应代码。 flatten(root, 0, &mut flat); + // 深度只影响缩进,选中身份用稳定节点 ID,起始行号另给 Action::Node 用于源码定位。 for (depth, n) in flat { let mut l = Line::new(format!("{}{} {}", " ".repeat(depth), n.kind, n.name), TEXT); l.selected = state.selected_node_id.as_deref() == Some(&n.id); @@ -136,6 +150,7 @@ pub(super) fn build( .selected_node_id .as_deref() .and_then(|id| doc.node(id)); + // 展示副本统一换行并展开 Tab,所以选择复制的是工具显示文本;原 doc.source 保持不变。 let code_lines = doc .source // 仅显示投影统一 CRLF 并把 Tab 展开为四空格;原始文档文本保持不变。