//! Console/Sources/Network/Settings 的宿主交互协调与绘制状态。 //! 工具标签有独立焦点、滚动和文本选择状态;源程序与结果来自当前发布文档,提交动作通过本地 API 进入 worker。 //! 输入法预编辑、光标、选择和控件捕获分别处理,避免把界面操作直接当成业务模型写入。 //! //! Native developer workspace. Coordinates and scroll offsets are logical pixels. //! //! shell::app 把命名编辑键、文本/IME 与指针交给当前 Workspace 槽,scene 调用 build 生成下一帧控件和命中布局。 //! activate 只在控件释放确认或文本框按下后消费 Action;build/Painter 只登记 Action,不在绘制时提交 Console 输入。 //! submit_console/initialize_tools 的后台线程只做本地 HTTP 传输;VM 执行结果由 API 活动日志展示,不以线程启动或读完响应判定成功。 use crate::{ activity::{Activity, Exchange}, app::AppEvent, display_list::{Color, DisplayList, DrawCommand}, document::{CodeNode, RenderDocument}, geometry::{Point, Rect}, sources::SourceWorkspace, system_ui::{ Axis, Button, Cursor, Interaction, Role, Target, Ui, WidgetId, theme::{BAR, BG, BORDER, ERROR, MUTED, OK, PALETTE, TEXT, Theme, readable_accent}, }, text::{self, TextStyle}, }; use std::{ io::{Read, Write}, net::TcpStream, thread, time::Duration, }; use unicode_segmentation::UnicodeSegmentation; mod console; mod network; mod painter; mod selection; mod settings; mod sources; use painter::*; use selection::SourceSelection; // Console、Painter 和 SourceSelection 共用逻辑行高;显示行号、光标导航和滚动必须采用同一值。 const ROW: f32 = 19.0; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] /// 当前管理工具种类,决定使用 Console/Sources/Network/Settings 哪份状态。 pub enum Tab { #[default] Console, Sources, Network, Settings, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] /// 管理面板中可区分的输入/列表/详情区域。 // 枚举值同时作为 scroll/widths 的数组下标,新增区域时须同步调整两组数组长度。 pub enum Region { Transcript = 0, Tree, Source, Requests, Details, } #[derive(Clone, Copy, Debug, Default)] /// 工具区域保留的两轴滚动偏移;内容范围由本帧布局另行保存。 struct Scroll { x: f32, y: f32, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] /// 当前工具内接受键盘输入的目标类别。 pub enum Focus { Source, Input, Filter, Custom, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] /// 工具控件激活后执行的管理动作,避免在绘制时产生业务副作用。 pub enum Action { ClearConsole, ClearNetwork, Follow, ResetSession, Focus(Focus), Snapshot, Cloud, CloudRefresh, CloudOpen(String), Node(String, usize), Request(u64), Theme(usize), } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] /// 工具画面分隔布局的可调整方向/区域。 pub enum Split { Tree, Network, } #[derive(Clone, Copy, Debug)] /// 正在捕获的工具拖动操作,移动时保持原目标。 enum Drag { Split(Split), // 记录区域、是否横轴以及手柄位移转内容位移的倍率;倍率来自 Painter,而不是每次移动重新估算。 Scroll(Region, bool, f32), } /// Each workspace keeps its own input, focus, selection, scroll and request state. /// Only appearance and the window-level launcher are shared. /// 多工具页的状态拥有者,切换时保存/恢复当前页数据。 pub struct Workspace { // 数组顺序与 new/select 的工具映射一致,每个槽独立保留输入、日志水位、选区和滚动。 states: [DeveloperToolsState; 4], active: usize, } impl Workspace { /// 为 Console/Sources/Network/Settings 各创建一份独立状态,共享事件循环代理,初始选择 Console。 // shell/app 创建窗口宿主状态时调用,代理克隆让各工具的异步请求错误能唤醒同一个原生事件循环。 pub fn new(proxy: winit::event_loop::EventLoopProxy) -> Self { // 对四种工具各创建一个默认状态,再覆盖 tab/proxy,避免多个工具误共享一个可编辑 input。 let states = [Tab::Console, Tab::Sources, Tab::Network, Tab::Settings].map(|tab| { DeveloperToolsState { tab, proxy: Some(proxy.clone()), ..Default::default() } }); Self { states, active: 0 } } /// 选择一个工具页并恢复它各自保留的输入、焦点和滚动状态。 // scene::build_scene 根据活动页面的 developer_tool 标记调用;None 关闭工具显示,未知非空名字使用 Console 槽。 pub fn select(&mut self, name: Option<&str>) { // 名字仅选择已有状态槽,不在这里创建 VM 页面;页面复用/创建通过 initialize_tools 的能力调用完成。 let next = match name { Some("Sources") => 1, Some("Network") => 2, Some("Settings") => 3, _ => 0, }; // 每个工具保留自己的文本/滚动/选择,强调色在切换时从旧活动页复制;切走先 blur 解除捕获与输入法状态。 let accent = self.states[self.active].accent; if next != self.active { self.states[self.active].blur(); } self.active = next; self.states[next].accent = accent; self.states[next].open = name.is_some(); } } impl std::ops::Deref for Workspace { type Target = DeveloperToolsState; /// 将包装类型的只读访问转交给内部当前上下文。 // scene/app 的字段读取和 build 通过当前槽访问状态,不会在一次读取时遍历其他工具。 fn deref(&self) -> &Self::Target { &self.states[self.active] } } impl std::ops::DerefMut for Workspace { /// 将包装类型的可变访问转交给内部当前上下文。 // 原生输入事件调用当前槽方法,切换工具前 blur 旧槽解除交互状态。 fn deref_mut(&mut self) -> &mut Self::Target { &mut self.states[self.active] } } #[derive(Debug)] /// 一份工具页面的输入、选择、滚动、焦点和显示配置。 pub struct DeveloperToolsState { // 是否当前展示管理页;关闭时 build 返回空列表/布局,状态本身仍保留供后续重新打开。 pub open: bool, // 最近工具逻辑坐标,cursor_kind 复用它判断文本区或控件悬停。 pointer: Point, source_selection: SourceSelection, // 底层宿主控件焦点、悬停及按下捕获;与文本编辑 focus/drag 配合,不直接改变业务 VM。 pub interaction: Interaction, // 绘制 reconcile 或源码拖选置位,shell/app 据此安排额外重绘,修正失效控件外观。 pub repaint: bool, // 后台线程只携带事件代理和自有文本,不借用窗口或 DeveloperToolsState。 pub proxy: Option>, pub cloud_sources: SourceWorkspace, pub tab: Tab, pub selected_node_id: Option, pub focus: Option, pub input: String, // Console 光标是 UTF-8 字节位置,移动和删除按 grapheme 边界维护,不用字符下标直接切字符串。 pub cursor: usize, // 输入框表示全选状态,Sources 则结合 SourceSelection 表示全选;并非通用起止字节范围。 pub selection: bool, // IME 未提交的组合文本,只在 Console 显示副本插入;Commit 后才通过 insert 写正式 input。 pub preedit: String, pub filter: String, pub custom: String, pub accent: Color, pub input_error: String, // Sources 左栏宽度及 Network 上下比例分别控制分隔布局,拖动只调整工具空间。 tree_width: f32, network_fraction: f32, scroll: [Scroll; 5], // 各 Region 独立保存行文本指纹与最大宽度,颜色/选择改变无需重测,文本改变才失效。 widths: [Option<(u64, f32)>; 5], // 下一帧需要显示编辑光标,console::build 测量位置后消费;不是立即修改滚动的命令。 reveal_input: bool, // Console 输出行指纹,用于新增输出时 Follow;当前未提交 input 不参与这份指纹。 console_stamp: u64, drag: Option, cloud: bool, follow: bool, // 稳定日志 ID,筛选隐藏行仍可保留详情;日志淘汰后详情页回到提示。 selected_request: Option, // 清屏水位只隐藏旧记录,与共享 ActivityLog 的实际淘汰分开;Network 有独立水位。 console_after: u64, network_after: u64, // 最多 80 条成功启动发送线程的输入,不等于成功执行历史;失败线程可能再由事件恢复输入。 history: Vec, history_index: Option, // 首次进入历史导航前保存当前未提交输入,向下越过最新历史后恢复此草稿。 draft: String, } impl Default for DeveloperToolsState { /// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。 // Workspace::new 用它构造四份独立状态;初始不开工具、无焦点,默认启用 Follow 并请求显示输入。 fn default() -> Self { Self { open: false, pointer: Point::default(), source_selection: SourceSelection::default(), interaction: Interaction::default(), repaint: false, proxy: None, cloud_sources: SourceWorkspace::default(), tab: Tab::Console, selected_node_id: None, focus: None, input: String::new(), cursor: 0, selection: false, preedit: String::new(), filter: String::new(), custom: "#1671D5".into(), accent: PALETTE[0], input_error: String::new(), tree_width: 190.0, network_fraction: 0.42, scroll: [Scroll::default(); 5], widths: [None; 5], reveal_input: true, console_stamp: 0, drag: None, cloud: false, follow: true, selected_request: None, console_after: 0, network_after: 0, history: Vec::new(), history_index: None, draft: String::new(), } } } #[derive(Clone, Debug, Default)] /// 本帧工具面板与子区域的几何,用于绘制和原生事件命中。 pub struct DeveloperToolsLayout { // 整工具画布的逻辑区域;shell/app 先据此决定输入是否属于管理控件。 pub panel: Rect, pub widgets: Vec>, splits: Vec<(Rect, Split)>, // 各行面板的区域和完整内容宽高,wheel 查命中,Painter 重绘时根据内容限制最大偏移。 regions: Vec<(Rect, Region, f32, f32)>, // 滚动手柄矩形及内容/手柄位移倍率,press 找到后保存到 Drag::Scroll。 thumbs: Vec<(Rect, Region, bool, f32)>, input: Option, // 源码可见区、已扣滚动/行号的正文原点和完整显示文本,鼠标选择与复制共享本帧数据。 source: Option<(Rect, Point, Vec)>, } impl DeveloperToolsLayout { /// 将工具局部绘制/交互布局平移到实际窗口位置。 // scene::build_scene 把工具移动到侧栏右边时调用;显示列表另以设备比例平移,这里保持逻辑坐标。 pub fn translate(&mut self, dx: f32, dy: f32) { // 绘制列表已由外层按设备像素平移,这里所有命中矩形和文字原点都按逻辑单位同步移动。 self.panel.x += dx; self.panel.y += dy; // 先同步所有通用 Widget 命中,后面再移动额外的分隔条、区域和手柄矩形。 for target in &mut self.widgets { target.rect.x += dx; target.rect.y += dy; } for (rect, _) in &mut self.splits { // 分隔条的额外布局与 Widget 目标同时移动,后续 press 才能在平移后的窗口位置找到拖动类别。 rect.x += dx; rect.y += dy; } for (rect, _, _, _) in &mut self.regions { // 滚轮用区域外框定位内容;只平移外框,不改 Region 身份和完整内容宽高。 rect.x += dx; rect.y += dy; } for (rect, _, _, _) in &mut self.thumbs { // 手柄几何随窗口平移,已计算的轴方向/内容位移倍率保持不变,不能把平移量当滚动增量。 rect.x += dx; rect.y += dy; } // 输入视口与正文原点必须一起移动,才能让鼠标插入点和 IME 候选窗对齐显示内容。 if let Some(input) = &mut self.input { input.viewport.x += dx; input.viewport.y += dy; input.origin.x += dx; input.origin.y += dy; } // 源码正文行文本不变,只移动选择的可见区域和文字原点,字节端点不随窗口平移变化。 if let Some((area, origin, _)) = &mut self.source { area.x += dx; area.y += dy; origin.x += dx; origin.y += dy; } } } #[derive(Clone, Copy, Debug)] /// Console 文字输入、光标与可见范围的本帧排版。 struct ConsoleInputLayout { viewport: Rect, origin: Point, } impl DeveloperToolsState { /// 根据最新源文档修正 Sources 的已选 AST 节点,保留仍有效的选择。 // scene 构建工具画面前调用,仅修正 AST 选择身份;输入与 HTTP 历史来自另外的状态和活动日志。 pub fn document_updated(&mut self, doc: &RenderDocument) { // 已选 AST ID 在新文档不存在时回退根;有效选择继续保留,不按行号盲目关联另一份代码。 if self .selected_node_id .as_deref() .is_none_or(|id| doc.node(id).is_none()) { self.selected_node_id = doc.root.as_ref().map(|n| n.id.clone()); } } /// 根据当前工具命中区域选择文本、拖动或默认系统光标。 // shell/app 更新原生光标时调用;Sources 文本区域优先返回 I-beam,其余用同帧 Widget 状态映射。 pub fn cursor_kind(&self, layout: &DeveloperToolsLayout) -> winit::window::CursorIcon { use winit::window::CursorIcon; // 工具关闭时不让旧布局或悬停状态影响原生光标。 if !self.open { return CursorIcon::Default; } if layout .source .as_ref() .is_some_and(|(area, _, _)| area.contains(self.pointer)) { // 只读源码选择区先于普通 Widget 光标,显示 I-beam 并不赋予源码可编辑或可执行的能力。 return CursorIcon::Text; } // 宿主抽象 Cursor 转换成 winit CursorIcon,保留禁用、抓取与横纵调整等语义。 match self.interaction.cursor(&layout.widgets) { Cursor::Default => CursorIcon::Default, Cursor::Pointer => CursorIcon::Pointer, Cursor::Text => CursorIcon::Text, Cursor::ResizeHorizontal => CursorIcon::ColResize, Cursor::ResizeVertical => CursorIcon::RowResize, Cursor::Grab => CursorIcon::Grab, Cursor::Disabled => CursorIcon::NotAllowed, } } /// 委托宿主控件处理指针离开,具体捕获是否保留由 Interaction 决定。 // shell/app 收到原生指针离开窗口时调用,委托 Interaction 清理离开状态,不清空输入文本。 pub fn pointer_left(&mut self) { self.interaction.pointer_left(); } /// 清理指针或焦点离开后的悬停/捕获状态,避免按下状态残留。 // 工具切换和窗口焦点转移路径调用,清焦点/拖动/preedit,保留输入草稿、历史及滚动。 pub fn blur(&mut self) { self.interaction.blur(); self.drag = None; self.focus = None; self.preedit.clear(); } /// 记录按下时的控件或文本选择目标,为释放激活和拖动保留捕获。 // shell/app 路由到工具的鼠标按下入口;仅当前打开面板内输入能建立控件捕获或源码拖选。 pub fn press(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) { // 不处理工具外按下,避免旧命中几何抢走业务页面交互。 if !self.open || !layout.panel.contains(p) { return; } // 鼠标重新定位输入目标时结束旧预编辑显示,避免把输入法组合文本残留到另一控件。 self.preedit.clear(); self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); // 先交给同帧 Widget 集合解析,滚动条和分隔条等控件命中优先于大面积源码文字区域。 let target = self.interaction.press(&layout.widgets); self.focus = None; if let Some((area, origin, lines)) = &layout.source && area.contains(p) // 只有未被滚动条/分隔条等控件命中时才开始源码拖选,管理控件优先于文字区域。 && target.is_none() { self.focus = Some(Focus::Source); self.selection = false; self.source_selection.begin(p, *origin, lines); } // 不同角色决定按下即开始拖动、立即给文本焦点,或仅保留捕获等待释放激活。 if let Some(target) = target { match target.role { // 细分隔线的交互矩形向四周扩 3 单位,布局条目提供拖动类别而不是用屏幕位置猜测。 Role::Splitter(_) => { if let Some((_, split)) = layout.splits.iter().find(|(r, _)| { Rect::new(r.x - 3.0, r.y - 3.0, r.width + 6.0, r.height + 6.0).contains(p) }) { self.drag = Some(Drag::Split(*split)); } } // 找到对应手柄时复制区域、轴与倍率,之后指针移出手柄仍对同一区域拖动。 Role::Scrollbar(_) => { if let Some((_, region, h, ratio)) = layout.thumbs.iter().find(|(r, _, _, _)| r.contains(p)) { self.drag = Some(Drag::Scroll(*region, *h, *ratio)); } } // 文本框按下立即取得编辑焦点/定位光标;普通按钮动作仍等 release 确认同一启用目标。 Role::TextField => self.activate(target.action, layout, p, activity), _ => {} } } } /// 执行已确认激活的管理控件动作,区分工具切换、源码操作和设置。 fn activate( &mut self, action: Option, layout: &DeveloperToolsLayout, p: Point, activity: &Activity, ) { self.focus = None; // 所有确认的控件动作集中处理;绘制模块只构造 Action,不在创建控件时执行请求或主题更新。 match action { Some(Action::Focus(f)) => { self.focus = Some(f); self.selection = false; if f == Focus::Input && let Some(input) = layout.input { // 根据本帧输入原点和固定行高定位行,逐字素测量字节结束位置决定插入点,不能按等宽字符数量切中文。 let row = (((p.y - input.origin.y) / ROW).floor().max(0.0) as usize) .min(self.input.split('\n').count() - 1); // 累计前面各行的 UTF-8 字节长度及换行字节,行内测得 offset 后再合成整个 input 的 cursor。 let mut start = 0; let line = self .input .split('\n') .enumerate() .find_map(|(i, line)| { // 命中目标行就借用该行,未到达时累加它连同分隔换行占用的字节。 if i == row { Some(line) } else { start += line.len() + 1; None } }) .unwrap_or(""); // 转换到正文的横向距离,点击提示符或左边距按非负目标处理;行内定位使用字素宽度。 let target = (p.x - input.origin.x).max(0.0); let mut offset = line.len(); // 与 Sources 的中点算法不同,这里取首个末端宽度不小于目标的字素起点。 for (i, part) in line.grapheme_indices(true) { let advance = text::measure(&line[..i + part.len()], TextStyle::code()).width; // 找到包含目标 x 的字素即停止;超过全行时 offset 维持行尾。 if advance >= target { offset = i; break; } } self.cursor = (start + offset).min(self.input.len()); self.reveal_cursor(); } } // 清屏只更新水位/滚动,不删除 VM 全局对象;ResetSession 才向 API 请求清会话。 Some(Action::ClearConsole) => { self.console_after = activity.lock().unwrap().records.back().map_or(0, |r| r.id); self.scroll[Region::Transcript as usize] = Scroll::default(); self.reveal_cursor(); } // 清屏同时取消详情选择,但不改变共享日志或 Console 的清屏水位。 Some(Action::ClearNetwork) => { self.network_after = activity.lock().unwrap().records.back().map_or(0, |r| r.id); self.selected_request = None; } // 切换后清输出指纹,使下一次 Console 构建重新判断跟随;滚动操作本身在绘制时完成。 Some(Action::Follow) => { self.follow = !self.follow; self.console_stamp = 0; } Some(Action::ResetSession) => self.submit_console(true), Some(Action::Snapshot) => self.cloud = false, Some(Action::Cloud) => self.cloud = true, Some(Action::CloudRefresh) => self.cloud_sources.refresh(), // provider 同步读取结果保存在 cloud_sources,无论成功与否都把源码滚动移回起点。 Some(Action::CloudOpen(id)) => { self.cloud_sources.open(&id); self.scroll[Region::Source as usize] = Scroll::default(); } // 选中 AST 身份并跳到一基起始行,saturating_sub 兼容零值而不发生无符号下溢。 Some(Action::Node(id, line)) => { self.selected_node_id = Some(id); self.scroll[Region::Source as usize].x = 0.0; self.scroll[Region::Source as usize].y = line.saturating_sub(1) as f32 * ROW; } // 在 Console 选外部请求标题可切 Network 并清筛选,让目标记录可见;详情滚动回顶部。 Some(Action::Request(id)) => { self.selected_request = Some(id); if self.tab == Tab::Console { // 点击 Console 的请求标题转到该状态的 Network 视图,清筛选/水位让目标记录可见;不重新发送请求。 self.tab = Tab::Network; self.network_after = 0; self.filter.clear(); } self.scroll[Region::Details as usize] = Scroll::default(); } // 索引来自 PALETTE 色块生成的动作,直接更新强调色并把自定义编辑文本同步成六位 hex。 Some(Action::Theme(i)) => { self.accent = PALETTE[i]; self.custom = format!( "#{:02X}{:02X}{:02X}", self.accent.red, self.accent.green, self.accent.blue ); } None => {} } } /// 结束捕获并按同一目标/启用条件决定是否执行操作。 // shell/app 的鼠标抬起入口;先由 Interaction 验证捕获目标,再统一结束工具拖动与源码拖选。 pub fn release(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) { self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); // release 只为原先捕获且仍启用的同目标返回 Action,不能按当前鼠标位置随意激活另一个按钮。 let action = self.interaction.release(&layout.widgets); self.drag = None; self.source_selection.dragging = false; // 只有返回有效动作才 activate,否则只释放捕获,不因抬起点恰好碰到别的控件触发它。 if action.is_some() { self.activate(action, layout, p, activity); } } /// 按工具控件焦点处理 Tab、确认等管理键盘交互。 // shell/app 在普通编辑 key 前调用;返回 true 表示已经消费控件导航/激活,不再落入 Console 文本逻辑。 pub fn keyboard_control( &mut self, event: &winit::event::KeyEvent, shift: bool, layout: &DeveloperToolsLayout, activity: &Activity, ) -> bool { use winit::{ event::ElementState, keyboard::{Key, NamedKey}, }; // 输入法正在组合时不抢 Enter/Space 等控件键盘激活,避免确认候选被误触发按钮。 if !self.open || !self.preedit.is_empty() { return false; } // 没有文本焦点时 Escape 清宿主控件焦点;按下和抬起都消费,只有按下执行 blur。 if event.logical_key == Key::Named(NamedKey::Escape) && self.focus.is_none() { if event.state == ElementState::Pressed { self.interaction.blur(); } return true; } // Console 输入内普通 Tab 插四空格;Shift+Tab 或其他焦点的 Tab 走控件循环导航。 if event.logical_key == Key::Named(NamedKey::Tab) && (self.focus != Some(Focus::Input) || shift) { if event.state == ElementState::Pressed && !event.repeat { // 仅非重复按下推进焦点;目标含 Focus 动作才映射成文本焦点,否则保留控件导航焦点。 let target = self.interaction.focus_next(&layout.widgets, shift); self.focus = target.and_then(|t| match t.action { Some(Action::Focus(f)) => Some(f), _ => None, }); if self.focus == Some(Focus::Input) { // 键盘导航进入 Console 编辑器时将插入点放到当前草稿末尾,再由下一帧测量并滚到可见范围。 self.cursor = self.input.len(); self.reveal_cursor(); } } return true; } // Enter/Space 仅激活非 TextField 控件,文本输入的这些键继续交 key 处理。 let control = self .interaction .focused(&layout.widgets) .is_some_and(|t| t.role != Role::TextField); if control && matches!( event.logical_key, Key::Named(NamedKey::Enter | NamedKey::Space) ) { if event.state == ElementState::Pressed { // 控件只在非自动重复按下时捕获,抬起时返回动作,避免长按 Enter 重复提交控制操作。 if !event.repeat { self.interaction.key_press(&layout.widgets); } } else { // 抬起时结束键盘捕获并取动作,按下自动重复不会反复执行按钮。 let action = self.interaction.key_release(&layout.widgets); self.activate(action, layout, Point::default(), activity); } return true; } false } /// 更新工具内悬停、拖动、选择或分隔条位置。 // shell/app 每次指针移动传入当前位置和增量,更新悬停、源码拖选及已捕获拖动;不重新选择 Drag 目标。 pub fn move_pointer( &mut self, p: Point, delta: Point, layout: &DeveloperToolsLayout, _window_width: f32, ) { self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); if self.source_selection.dragging && let Some((area, origin, lines)) = &layout.source { // 源码拖选越出视口时每次指针移动增加/减少一行滚动;不是单独定时器持续自动滚动。 if p.y < area.y || p.y > area.y + area.height - 9.0 { let direction = if p.y < area.y { -ROW } else { ROW }; self.scroll[Region::Source as usize].y = (self.scroll[Region::Source as usize].y + direction).max(0.0); } self.source_selection.extend(p, *origin, lines); self.repaint = true; } // 分隔条按绝对指针位置计算,滚动条按增量乘捕获倍率计算,两种几何语义分开。 match self.drag { // 树宽至少 120,且尽量给右侧源码保留 180;窄窗口通过 max 保持 clamp 上下界合法。 Some(Drag::Split(Split::Tree)) => { self.tree_width = (p.x - layout.panel.x).clamp(120.0, (layout.panel.width - 180.0).max(120.0)) } // 把指针 y 转成上下区比例并限于 20%..80%,避免拖动把某个区域压到完全不可访问。 Some(Drag::Split(Split::Network)) => { self.network_fraction = ((p.y - layout.panel.y - 92.0) / (layout.panel.height - 114.0).max(1.0)) .clamp(0.2, 0.8) } // 手柄位移乘 Painter 保存的 content/travel 倍率转成内容偏移;手动滚动 transcript 会关闭自动 Follow。 Some(Drag::Scroll(r, h, ratio)) => { let s = &mut self.scroll[r as usize]; if h { // 捕获时保存的 h 决定只改横向偏移,ratio 将手柄逻辑位移转换成内容位移。 s.x = (s.x + delta.x * ratio).max(0.0); } else { s.y = (s.y + delta.y * ratio).max(0.0); } if r == Region::Transcript { // 用户主动拖动输出区后停用 Follow,否则下一帧会立即把刚选择的阅读位置拉回底部。 self.follow = false; } } None => {} } } /// 将滚轮作用到当前工具的可滚动区域,钳制本工具偏移。 // shell/app 路由工具区域滚轮时调用;按逆区域顺序命中一个面板,内容上界交下一次 Painter 统一计算。 pub fn wheel(&mut self, layout: &DeveloperToolsLayout, p: Point, dx: f32, dy: f32) { // 滚轮寻找最前景包含点的 Region,只钳下限;上限在下一轮绘制根据最新内容范围统一钳制。 if let Some((_, r, _, _)) = layout .regions .iter() .rev() .find(|(area, _, _, _)| area.contains(p)) { // r 来自本帧命中区域,选择该工具的独立滚动槽;不改变其他面板或其他 Workspace 页的偏移。 let s = &mut self.scroll[*r as usize]; s.x = (s.x - dx).max(0.0); s.y = (s.y - dy).max(0.0); if *r == Region::Transcript { // 滚轮与手柄拖动采用同样的 Follow 取消规则,下一轮 Painter 再钳最新内容上界。 self.follow = false; } } } /// 在当前选择/光标处插入输入文本,并更新光标与可见范围。 // 普通文本输入、IME Commit、粘贴及剪切成功后的清空共用;Source 焦点与无焦点不接受写入。 pub fn insert(&mut self, value: &str) { match self.focus { Some(Focus::Input) => { // 先检查当前输入+新文本字节总量,再处理全选替换;因此大输入全选后粘贴仍按此保守上限检查。 if self.input.len() + value.len() > 1_048_576 { self.input_error = "Input exceeds 1 MiB".into(); return; } // Input 全选替换只在预算检查通过后清原文,因此超大粘贴失败仍保留已有输入。 if self.selection { self.input.clear(); self.cursor = 0; self.selection = false; } self.input.insert_str(self.cursor, value); self.cursor += value.len(); // 编辑历史条目后退出历史导航,当前输入成为新的可提交文本;光标使用插入文本实际 UTF-8 字节数推进。 self.history_index = None; self.reveal_cursor(); } // 过滤框只追加无控制字符,并把请求列表纵向滚动归零;不改变已选请求身份。 Some(Focus::Filter) => { if self.selection { self.filter.clear(); self.selection = false; } self.filter // Filter 每次插入最多200个无控制字符;这是单次输入限制,不是累计字符串长度上限。 .extend(value.chars().filter(|c| !c.is_control()).take(200)); self.scroll[Region::Requests as usize].y = 0.0; } // 自定义色是编辑文本,允许的字符过滤不等于完整 hex 验证,Enter 才应用主题。 Some(Focus::Custom) => { if self.selection { self.custom.clear(); self.selection = false; } self.custom.extend( value .chars() // 颜色输入仅保留 hex/# 字符,每次最多7个;真正六位格式在 Enter 应用时验证。 .filter(|c| c.is_ascii_hexdigit() || *c == '#') .take(7), ); } Some(Focus::Source) | None => {} } } /// 调整输入区滚动位置,让当前光标保持在可见范围内。 // 只设置下一帧 reveal 标记,具体滚动到可见范围由 Console build 在测量光标位置后完成。 pub(crate) fn reveal_cursor(&mut self) { self.reveal_input = true; } /// 返回输入法候选窗应跟随的逻辑光标位置。 // shell/app 更新原生输入法候选位置时调用,使用本帧 Console 输入原点和正式 input 的 cursor,不追加 preedit。 pub fn ime_position(&self, layout: &DeveloperToolsLayout) -> Point { if let Some(input) = layout.input { // cursor 由编辑路径保持在有效字节边界,当前行前缀测量用于候选窗跟随,不按字符数估算 x。 let before = &self.input[..self.cursor]; let row = before.matches('\n').count() as f32; // col 只是当前行在光标之前的文本,按真实字形宽度测量;area 给候选窗位置的可见钳制范围。 let col = before.rsplit('\n').next().unwrap_or(""); let area = input.viewport; return Point::new( (input.origin.x + text::measure(col, TextStyle::code()).width) .clamp(area.x, area.x + area.width), (input.origin.y + row * ROW).clamp(area.y, area.y + area.height), ); } // 没有输入布局时返回工具内固定候选位置,不通过空 input 几何构造无效坐标。 Point::new(layout.panel.x + 20.0, 60.0) } /// 取得当前工具文本选择内容,供复制命令使用。 // shell/app 的复制/剪切快捷键调用,返回 String 后才交系统剪贴板;Source 只读插入不会修改显示源码。 pub fn selected_text(&self) -> String { // 源码支持任意拖选区间或全选;其他三个文本框当前只支持整框全选复制。 if self.focus == Some(Focus::Source) { return self.source_selection.text(self.selection); } if !self.selection { // 非源码文本框目前只有全选状态,无选择就返回空串,复制调用方据此保留系统现有剪贴板。 return String::new(); } match self.focus { Some(Focus::Input) => self.input.clone(), Some(Focus::Filter) => self.filter.clone(), Some(Focus::Custom) => self.custom.clone(), Some(Focus::Source) | None => String::new(), } } /// 处理编辑键和导航键,维护 UTF-8 光标/选择边界。 // shell/app 处理命名编辑键调用;输入法组合期间外层已过滤原始编辑键,提交文本走 insert。 pub fn key(&mut self, key: &winit::keyboard::NamedKey, shift: bool, command: bool) { use winit::keyboard::NamedKey::*; // Escape 在任何文本焦点先处理,结束输入组合与宿主控件焦点,不提交 Console 草稿。 if *key == Escape { self.focus = None; self.preedit.clear(); self.interaction.blur(); return; } // 源码使用独立 SourceSelection 导航,不把只读源码端点写成 Console cursor;成功移动安排重绘。 if self.focus == Some(Focus::Source) { if let Some(row) = self.source_selection.navigate(key, shift, command) { self.selection = false; self.scroll[Region::Source as usize].y = (row as f32 * ROW - ROW * 4.0).max(0.0); self.repaint = true; } return; } // 应用主题时解析六位 RGB 并调整过亮强调色,失败保留原主题并写输入错误。 if self.focus == Some(Focus::Custom) && *key == Enter { // 允许开头一个或多个 # 被剥除,余下必须恰好六个 ASCII hex 字节;不是单纯按输入字符过滤就接受。 let hex = self.custom.trim_start_matches('#'); if hex.len() == 6 && let Ok(v) = u32::from_str_radix(hex, 16) { self.accent = readable_accent(Color::rgb((v >> 16) as u8, (v >> 8) as u8, v as u8)); self.input_error.clear(); } else { self.input_error = "Use a six-digit hex color, e.g. #1671D5".into(); } return; } // 其他文本框仅处理 Backspace,删除末尾 Unicode 标量或全选内容,没有独立光标导航。 if self.focus != Some(Focus::Input) { if *key == Backspace { match self.focus { Some(Focus::Filter) => { if self.selection { // Filter 全选时删整框,否则下方 pop 删除末尾 Unicode 标量;这里没有独立字节光标。 self.filter.clear(); } else { self.filter.pop(); } } Some(Focus::Custom) => { if self.selection { // 颜色编辑框遵循同样的全选删除规则,删除文本本身不会立即应用或重置强调色。 self.custom.clear(); } else { self.custom.pop(); } } _ => {} } self.selection = false; } return; } // Console 的 Enter/Tab/Space、删除和导航共用字节 cursor;末尾统一请求下一帧显示光标。 match key { Enter if !shift => self.submit(), Enter => self.insert("\n"), Tab => self.insert(" "), Space => self.insert(" "), // 全选先删整个输入,否则分别找前/后完整字素边界,只替换对应字节区间。 Backspace | Delete => { if self.selection { self.input.clear(); self.cursor = 0; self.selection = false; } else if *key == Backspace && self.cursor > 0 { // 光标已在有效字节边界,向前查最后一个完整字素的起点,再删除到当前 cursor 的整个区间。 let n = self.input[..self.cursor] .grapheme_indices(true) .next_back() .map_or(0, |(i, _)| i); // Backspace 删除前一完整字素;Delete 删除后一完整字素,组合音标和 emoji 不会拆成半个可见字符。 self.input.replace_range(n..self.cursor, ""); self.cursor = n; } else if *key == Delete && self.cursor < self.input.len() { // 后缀非空才取第一个字素长度,删除精确字节区间而不移动当前插入点。 let len = self.input[self.cursor..] .graphemes(true) .next() .unwrap() .len(); self.input.replace_range(self.cursor..self.cursor + len, ""); } } // command 直接到输入开头,普通按键退一个字素;这套 Console 选择当前只支持全选,移动会取消它。 ArrowLeft => { self.cursor = if command { 0 } else { self.input[..self.cursor] .grapheme_indices(true) .next_back() .map_or(0, |(i, _)| i) }; self.selection = false; } // command 直接到输入末尾,否则加后缀第一个完整字素的字节长度,行分隔也按文本字素遍历。 ArrowRight => { self.cursor = if command { self.input.len() } else { self.cursor + self.input[self.cursor..] .graphemes(true) .next() .map_or(0, str::len) }; self.selection = false; } // 普通 Home 使用当前行前一个换行之后的位置,command 则到整份输入的字节 0。 Home => { self.cursor = if command { 0 } else { self.input[..self.cursor].rfind('\n').map_or(0, |i| i + 1) }; self.selection = false; } // 普通 End 找当前 cursor 后首个换行,command 则用完整字符串长度。 End => { self.cursor = if command { self.input.len() } else { self.input[self.cursor..] .find('\n') .map_or(self.input.len(), |i| self.cursor + i) }; self.selection = false; } // command+上下或单行输入进入历史,普通多行上下则按字素列定位相邻行,两种导航职责分开。 ArrowUp | ArrowDown if command || !self.input.contains('\n') => { if self.history.is_empty() { return; } // 仅首次进入历史保存草稿,连续翻旧记录不会把历史内容覆盖原草稿。 if self.history_index.is_none() { self.draft = self.input.clone(); } // 未进入历史时视为位于列表末端,向上进最近一项;向下越过末端恢复事先保存草稿。 let current = self.history_index.unwrap_or(self.history.len()); let index = if *key == ArrowUp { current.saturating_sub(1) } else { (current + 1).min(self.history.len()) }; // 仅真实历史下标存 Some,末端用 None 表示草稿;恢复文本后光标统一放到末尾。 self.history_index = (index < self.history.len()).then_some(index); self.input = self .history_index .map_or_else(|| self.draft.clone(), |i| self.history[i].clone()); self.cursor = self.input.len(); } ArrowUp | ArrowDown => { let start = self.input[..self.cursor].rfind('\n').map_or(0, |i| i + 1); // 多行垂直导航保持字素列数,不保持像素 x;目标行较短时自然停到行尾。 let col = self.input[start..self.cursor].graphemes(true).count(); // 求相邻行的起始字节位置;第一行向上取 0,末行向下取文本末尾。 let target = if *key == ArrowUp { if start == 0 { 0 } else { self.input[..start - 1].rfind('\n').map_or(0, |i| i + 1) } } else { self.input[self.cursor..] .find('\n') .map_or(self.input.len(), |i| self.cursor + i + 1) }; // 限制目标行切片到下一个换行,不让列数继续消耗到后续行,较短行自然停在行尾。 let end = self.input[target..] .find('\n') .map_or(self.input.len(), |i| target + i); self.cursor = target + self.input[target..end] .graphemes(true) .take(col) .map(str::len) .sum::(); self.selection = false; } Escape => { self.focus = None; self.preedit.clear(); } _ => {} } self.reveal_cursor(); } /// 在线程中经本地 REPL 请求打开指定内置工具,页面复用/创建由 VM 的 developerTools 实现负责。 // shell/app 首次打开或请求工具入口时调用,后台向当前 app.main REPL 发送能力调用,不在 GUI 线程操作 VM。 pub fn initialize_tools(&mut self, name: &str) { // 把名称与代理克隆进后台线程,线程不借用 Workspace 或活动 Window 生命周期。 let name = name.to_owned(); let proxy = self.proxy.clone(); thread::spawn(move || { // 用调试字符串转义名称生成 Endor 文本,再经本地 REPL 执行;实际工具白名单由宿主能力验证。 let source = format!("ui.developerTools({name:?})"); // 连接本机固定 API,读取响应直到服务端关闭;这里只检查 I/O 成功,不解析 HTTP 状态或结果 JSON。 let send = || -> std::io::Result<()> { let mut stream = TcpStream::connect_timeout( &"127.0.0.1:4737".parse().unwrap(), Duration::from_secs(2), )?; stream.set_read_timeout(Some(Duration::from_secs(30)))?; write!( stream, "POST /v1/namespaces/app.main/repl HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}", source.len(), source )?; // 响应只用于读完连接,不在此解析状态码/JSON;HTTP 422 等应用错误仍由工具活动记录显示。 let mut response = String::new(); stream.read_to_string(&mut response)?; Ok(()) }; // 传输失败且有事件代理时通知 ConsoleFailed;初始化调用没有输入草稿,source 传空串。 if let Err(error) = send() && let Some(proxy) = proxy { // 仅传输失败走原生错误通知,source 为空表示初始化工具没有可恢复的用户输入草稿。 let _ = proxy.send_event(AppEvent::ConsoleFailed { message: error.to_string(), source: String::new(), }); } }); } /// 把当前工具输入按所选模式提交给本地 API,并异步反馈结果。 // Console key 的不带 Shift Enter 使用此入口,模式固定为普通持续会话提交。 fn submit(&mut self) { self.submit_console(false); } /// 提交 Console 输入到持久运行时,而不是把全部历史重新执行一遍。 // 普通 Enter 和 ResetSession 共用,reset 选择 DELETE,普通提交用 POST,二者只发送当前输入一次。 fn submit_console(&mut self, reset: bool) { // 空白输入不提交,reset 即使没有输入也有效;返回前不改已有输入/历史状态。 if !reset && self.input.trim().is_empty() { return; } // 普通提交克隆草稿,线程后续失败可带原文返回;重置不发送用户草稿内容。 let source = if reset { String::new() } else { self.input.clone() }; let proxy = self.proxy.clone(); // 后台仅发送一次当前输入或 DELETE 重置;源通过正文传递,传输失败带回原 source 让 GUI 恢复输入。 // 线程成功启动不等于源执行成功,HTTP 结果由 ActivityLog 在 Console 展示。 // 下方紧凑闭包只负责 HTTP 传输:2 秒连接、120 秒读取和 10 秒写入超时,响应正文不在客户端解析。 let spawned=thread::Builder::new().name("tessera-console-client".into()).spawn(move||{ let send=||->std::io::Result<()> { // stream 是本次后台请求独占的 TCP 连接,固定本机端点;不持 Workspace/Namespace 锁,也不在 GUI 线程等待。 let mut stream=TcpStream::connect_timeout(&"127.0.0.1:4737".parse().unwrap(),Duration::from_secs(2))?; stream.set_read_timeout(Some(Duration::from_secs(120)))?;stream.set_write_timeout(Some(Duration::from_secs(10)))?; // reset 发 DELETE 且正文为空,普通输入发 POST;Content-Length 使用 UTF-8 字节数,文本只作为正文传一次。 let method=if reset{"DELETE"}else{"POST"}; write!(stream,"{method} /v1/namespaces/app.main/repl HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nX-Tessera-Origin: console\r\nContent-Length: {}\r\n\r\n{}",source.len(),source)?; // read_to_string 只等待响应流结束,不把 HTTP 状态或 value/rendered 字段解释成客户端执行成功。 let mut response=String::new();stream.read_to_string(&mut response)?;Ok(()) }; // 传输错误才带原 source 回到原生线程;app 仅在编辑框仍为空时恢复,避免覆盖用户后来输入的草稿。 if let Err(error)=send()&&let Some(proxy)=proxy{let _=proxy.send_event(AppEvent::ConsoleFailed{message:error.to_string(),source});} }); // 按线程创建结果更新 GUI 状态,不等待网络或 VM 执行;失败创建线程时保留输入并显示错误。 match spawned { // 发送线程启动成功才清输入并加入最多80条历史;之后若网络失败由 ConsoleFailed 反馈,不能把清框当执行成功。 Ok(_) => { if !reset { self.history.push(self.input.clone()); // 只留最近 80 次已启动提交,超额从最旧项移除;这与共享 HTTP 活动日志的淘汰分别记账。 if self.history.len() > 80 { self.history.remove(0); } self.input.clear(); self.cursor = 0; self.history_index = None; self.selection = false; self.reveal_cursor(); } self.follow = true; self.input_error.clear(); } Err(error) => self.input_error = error.to_string(), } } } /// 按当前工具生成设备比例显示列表与逻辑坐标命中布局,绘制后校正跨帧焦点和捕获。 // scene::build_scene 传入设备像素宽高和比例,返回设备显示列表加逻辑命中布局,外层再加侧栏横向偏移。 pub fn build( width: u32, height: u32, scale: f32, doc: &RenderDocument, state: &mut DeveloperToolsState, activity: &Activity, ) -> (DisplayList, DeveloperToolsLayout) { // 无工具页面时不读取活动日志或生成控件,空 layout 同时移除当前可命中区域。 if !state.open { return (Default::default(), Default::default()); } // 布局和命中统一用逻辑像素,最后仅显示列表按设备 scale 转换,返回 layout 仍为逻辑坐标。 let width = width as f32 / scale; let height = height as f32 / scale; // w/x 只是本帧工具局部画布的宽度和起点别名,侧栏偏移由 scene 外层平移,不在每个子工具里重复扣除。 let w = width; let x = 0.0; let panel = Rect::new(x, 0.0, w, height); // Painter 克隆当前 Interaction 供控件外观和目标生成;最后 reconcile 在原 state 上清理失效焦点。 let mut p = Painter { ui: Ui::new(Theme::new(state.accent), state.interaction.clone()), layout: DeveloperToolsLayout { panel, ..Default::default() }, }; p.fill(panel, BG); p.ui.list.push(DrawCommand::PushClip(panel)); p.fill(Rect::new(x, 0.0, w, 31.0), BAR); p.label( 12.0, 8.0, match state.tab { Tab::Console => "Console · app.main", Tab::Sources => "Sources · read only", Tab::Network => "Network · captured requests", Tab::Settings => "Settings · appearance", }, MUTED, false, ); // 锁内只克隆最多80条活动记录,接下来的文字排版/测量不持日志锁,HTTP worker 可继续更新。 let records = activity .lock() .unwrap() .records .iter() .cloned() .collect::>(); let bottom = height - 22.0; // 仅当前选中的子工具构建内容,共用这次 records 快照和 Painter,底栏与分隔条仍由本入口收尾。 match state.tab { Tab::Console => console::build(&mut p, state, &records, panel), Tab::Sources => sources::build(&mut p, state, doc, panel), Tab::Network => network::build(&mut p, state, &records, panel), Tab::Settings => settings::build(&mut p, state, panel), } p.fill(Rect::new(x + 1.0, bottom, w - 1.0, 22.0), BAR); p.label( x + 9.0, bottom + 3.0, &text::fit( &match state.tab { Tab::Console => "Live session · Enter to run".into(), Tab::Sources => "Read only · drag to select · ⌘/Ctrl+C to copy".into(), Tab::Network => format!( "{} captured requests", records .iter() .filter(|r| r.id > state.network_after) .count() ), Tab::Settings => "Appearance · current window session".into(), }, w - 20.0, TextStyle::code(), ), MUTED, false, ); // Splitters must be above toolbars, pane backgrounds and status bars for their // whole length. Register them last so their hit priority matches their paint order. // 分隔条最后绘制/注册,命中优先级与可见叠放相同,细线扩展命中区不会被后画的背景遮住。 // 逆登记顺序绘制覆盖层,拖动 Widget ID 包含 Split 类别,与布局条目保持同一身份。 for (rect, kind) in p.layout.splits.clone().into_iter().rev() { p.ui.splitter( rect, WidgetId::new(("split", kind)), match kind { Split::Tree => Axis::Horizontal, Split::Network => Axis::Vertical, }, ); } p.ui.list.push(DrawCommand::PopClip); // 把本管理 UI 当前使用的矩形、文字、裁剪、描边逐一转设备比例,几何与文字样式同倍率缩放。 let mut scaled = DisplayList::default(); // 只转换当前宿主管理 UI 实际生成的变体,其他命令原样克隆;新增变体若含坐标需检查这里的 scale 规则。 for cmd in p.ui.list.commands() { // 矩形转换闭包统一缩放原点和尺寸,文字还需单独缩放样式,描边还需缩放半径/线宽。 let rect = |r: &Rect| Rect::new(r.x * scale, r.y * scale, r.width * scale, r.height * scale); scaled.push(match cmd { DrawCommand::FillRoundedRect { rect: r, radius, color, } => DrawCommand::FillRoundedRect { rect: rect(r), radius: radius * scale, color: *color, }, DrawCommand::Text { origin, value, style, color, } => DrawCommand::Text { origin: Point::new(origin.x * scale, origin.y * scale), value: value.clone(), style: style.scaled(scale), color: *color, }, DrawCommand::PushClip(r) => DrawCommand::PushClip(rect(r)), DrawCommand::StrokeRoundedRect { rect: r, radius, width, color, } => DrawCommand::StrokeRoundedRect { rect: rect(r), radius: radius * scale, width: width * scale, color: *color, }, other => other.clone(), }); } // 移交同帧逻辑目标集合,不能从缩放后的显示列表重新猜控件几何。 p.layout.widgets = p.ui.targets; // 根据新目标集合清理失效焦点/捕获,若视觉状态变化要求再绘一帧,避免控件消失后保持旧按压外观。 state.repaint = state.interaction.reconcile(&p.layout.widgets); (scaled, p.layout) }