merge: resolve manuscript conflicts with latest main comments

This commit is contained in:
2026-11-12 11:11:00 +08:00
123 changed files with 2699 additions and 253 deletions
+11 -1
View File
@@ -8,8 +8,11 @@ use std::{
time::{Instant, SystemTime, UNIX_EPOCH},
};
// HTTP worker 与原生 Network 工具共享同一有界日志;持锁读取快照/更新记录,不在这里执行网络请求。
pub type Activity = Arc<Mutex<ActivityLog>>;
// 预览正文按 UTF-8 字节限制;截断标记会额外占用少量字节。
pub const BODY_PREVIEW: usize = 32 * 1024;
// 只保留最近 80 次开始的请求,尚未完成的老请求也可能被淘汰。
const MAX_RECORDS: usize = 80;
#[derive(Clone, Debug)]
@@ -22,10 +25,12 @@ pub struct Exchange {
pub timestamp: String,
pub request: String,
pub response: String,
// None 表示尚未记录响应;完成后保存 HTTP 状态码,不据此判断业务调用是否成功。
pub status: Option<u16>,
pub elapsed_ms: u128,
pub request_headers: String,
pub response_headers: String,
// 使用单调时钟计算耗时,不受系统时间调整影响;timestamp 则用于显示 UTC 时刻。
started: Instant,
}
@@ -40,6 +45,7 @@ impl ActivityLog {
pub fn begin(&mut self, method: String, path: String, origin: String, headers: String) -> u64 {
self.next_id += 1;
let id = self.next_id;
// 显示时间取 Unix 秒数的当日余数;系统时间早于纪元时回退到零。这里只显示时间,不保存日期。
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
@@ -64,18 +70,21 @@ impl ActivityLog {
response_headers: String::new(),
started: Instant::now(),
});
// 从队首淘汰最早开始的记录。后续 body/finish 若找不到其 ID 就忽略,不重新插入旧请求。
while self.records.len() > MAX_RECORDS {
self.records.pop_front();
}
id
}
/// 更新请求正文预览,内容长度仍由活动记录策略限制。
// 线性查找在最多 80 项范围内完成;本方法覆盖之前的请求预览,不拼接分块正文。
pub fn body(&mut self, id: u64, body: &str) {
if let Some(record) = self.records.iter_mut().find(|r| r.id == id) {
record.request = preview(body);
}
}
/// 完成本模块当前阶段的结果整理,把后续工作交回调用方。
/// 为指定请求登记响应状态、正文/头部预览与单调时钟耗时;记录已淘汰时忽略。
// 仅更新仍保留的请求,响应正文和头部都重新截断;耗时从 begin 起计算。
pub fn finish(&mut self, id: u64, status: u16, body: &str, headers: String) {
if let Some(record) = self.records.iter_mut().find(|r| r.id == id) {
record.status = Some(status);
@@ -91,6 +100,7 @@ pub fn preview(value: &str) -> String {
if value.len() <= BODY_PREVIEW {
return value.to_owned();
}
// UTF-8 多字节字符不能从中间切开;从字节上限回退到合法边界,确保输出始终是有效字符串。
let mut end = BODY_PREVIEW;
while !value.is_char_boundary(end) {
end -= 1;
+42 -5
View File
@@ -38,40 +38,49 @@ pub enum AppEvent {
/// 一个原生窗口的宿主状态;主窗口还协调子窗口资源,共用同一运行时与输入通道。
pub struct TesseraApp {
// 运行时窗口资源编号与 winit WindowId 分属不同命名空间,输入携带前者,OS 事件匹配后者。
resource: u64,
control: crate::api::RuntimeControl,
exit_requested: bool,
// 记录当前原生窗口所对应的运行时世代,避免重启后继续显示已失效的子窗口。
native_session: u64,
startup_page_requested: bool,
native_error: Option<String>,
// 只有主窗口维护此表;子窗口有独立文档/绘制状态,但复用运行时控制与输入队列。
children: std::collections::BTreeMap<u64, TesseraApp>,
proxy: EventLoopProxy<AppEvent>,
// 窗口、softbuffer 上下文与表面延迟创建;Arc 让表面持有有效的原生窗口句柄。
window: Option<Arc<Window>>,
context: Option<Context<Arc<Window>>>,
surface: Option<Surface<Arc<Window>, Arc<Window>>>,
namespace: Arc<RwLock<NamespaceState>>,
developer_tools: DeveloperToolsState,
// 最近一次绘制生成的宿主控件几何,用于下一次输入命中、光标和 IME 定位。
layout: SceneLayout,
pointer: Point,
sidebar: crate::sidebar::State,
sidebar_keyboard: bool,
panning: bool,
// 拖动开始时捕获视口,None 也可能表示拖动整张 Canvas,而非手势未开始。
pan_viewport: Option<u64>,
orbiting: bool,
space_down: bool,
modifiers: ModifiersState,
activity: Activity,
event_client: crate::events::Client,
// 保存按下时的版本与位置;释放时需确认仍处在同一会话与布局,防止旧画面误点击。
guest_pressed: Option<crate::events::Input>,
pointer_hover: Option<crate::events::Input>,
pointer_capture: Option<crate::events::Input>,
guest_focus: Option<crate::events::Input>,
// Enter/Space 的按下捕获;只在对应按键释放时发出一次 click,忽略系统连发。
guest_key: Option<(NamedKey, crate::events::Input)>,
guest_keyboard: bool,
}
impl TesseraApp {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
/// 先建立连接权威 namespace 的有界事件客户端,再初始化主窗口状态。
/// 客户端创建失败向调用方返回错误;原生窗口和像素表面延迟到 resumed 创建。
pub fn new(
namespace: Arc<RwLock<NamespaceState>>,
activity: Activity,
@@ -142,11 +151,12 @@ impl TesseraApp {
let session = namespace.runtime_session;
let revision = namespace.revision;
let submission = namespace.submission;
// 先复制已发布的子窗口投影并释放读锁,再创建/销毁 OS 资源;原生调用不会占着发布锁。
let documents = namespace.document.windows.clone();
drop(namespace);
if session != self.native_session {
// Console reset preserves the old projection with input disabled.
// A real replacement changes frame sessions and drops old OS views.
// 控制台 reset 可以保留旧投影但禁用输入;真正替换运行时后,
// 新文档带新 session,此时销毁旧原生视图,防止跨世代复用。
let replaces = documents
.values()
.any(|document| document.frame.session == session && session != 0);
@@ -203,6 +213,7 @@ impl TesseraApp {
return;
};
let session = namespace.runtime_session;
// 取走整批命令即表示消费;随后释放写锁,执行窗口 API 时不阻塞 worker 的文档发布。
let commands = std::mem::take(&mut namespace.window_commands);
drop(namespace);
for envelope in commands {
@@ -275,6 +286,7 @@ impl TesseraApp {
namespace.window_viewports.insert(self.resource, size);
if self.resource == crate::window::MAIN_WINDOW {
let session = namespace.runtime_session;
// 主窗口维护 worker 可见的视口总表:先移除旧世代/已关闭子窗口,再复制当前有效子窗口的逻辑尺寸。
namespace.window_viewports.retain(|id, _| {
*id == crate::window::MAIN_WINDOW
|| self
@@ -333,6 +345,7 @@ impl TesseraApp {
/// 接收完整文档更新,协调子窗口和原生命令,再更新输入焦点并请求重绘。
fn document_updated(&mut self, event_loop: &ActiveEventLoop) {
// 先使原生资源对应新文档,再执行其窗口命令;随后重建焦点令牌,避免沿用旧布局身份。
self.reconcile_windows(event_loop);
self.apply_window_commands();
if let Ok(namespace) = self.namespace.read() {
@@ -364,6 +377,7 @@ impl TesseraApp {
/// 先处理宿主管理输入、选择与快捷键,再按焦点决定是否交给页面。
fn handle_keyboard(&mut self, event: &winit::event::KeyEvent) {
// 宿主快捷键优先级最高;后续依次交给侧栏、页面交互、开发工具按钮及文本编辑。
let command = self.modifiers.super_key() || self.modifiers.control_key();
if command
&& event.state == ElementState::Pressed
@@ -391,6 +405,7 @@ impl TesseraApp {
}
return;
}
// 侧栏获得键盘路由后,Tab 在宿主目标间移动,Enter/Space 采用按下捕获、释放触发。
if self.sidebar_keyboard {
if event.logical_key == Key::Named(NamedKey::Tab)
&& event.state == ElementState::Pressed
@@ -434,6 +449,7 @@ impl TesseraApp {
return;
}
}
// 页面交互成功消费按键才提前返回;未处理的按键还可交给开发工具,保持明确的路由顺序。
if self.guest_keyboard && self.handle_guest_key(event) {
self.request_redraw();
return;
@@ -463,6 +479,7 @@ impl TesseraApp {
}
return;
}
// 只有展开且有编辑焦点的工具接受文本;剪切先成功写系统剪贴板,再删除选中内容。
if !self.developer_tools.open || self.developer_tools.focus.is_none() {
return;
}
@@ -490,6 +507,7 @@ impl TesseraApp {
}
_ => {}
}
// 输入法预编辑期间不再把原始按键转换为编辑动作;最终文本通过 Ime::Commit 插入。
} else if let Key::Named(key) = &event.logical_key {
if !self.developer_tools.preedit.is_empty() {
return;
@@ -548,6 +566,7 @@ impl TesseraApp {
if frame.session == 0 {
return None;
}
// 命中的是当前发布帧,事件同时携带 session/layout_version;worker 可拒绝排队期间已过期的输入。
let mount = frame.hit_test(point)?;
Some(crate::events::Input {
window: self.resource,
@@ -684,6 +703,7 @@ impl TesseraApp {
tab_drop: None,
screen_position: None,
});
// 滚轮优先交给可滚动挂载;未命中则尝试 3D 缩放或整页平移,最后更新开发工具自身滚动。
if let Some(input) = input {
self.send_guest(input);
}
@@ -726,6 +746,7 @@ impl TesseraApp {
return None;
}
let world = frame.surface.to_world(point);
// 拖动沿用捕获 ID,滚轮/缩放则按当前点命中;被捕获的视口若已消失,取消此次输入而不切换目标。
let viewport = if self.panning {
self.pan_viewport.and_then(|id| {
frame.mounts.iter().find(|mount| {
@@ -740,6 +761,7 @@ impl TesseraApp {
if self.panning && self.pan_viewport.is_some() && viewport.is_none() {
return None;
}
// 局部 2D/3D 视口使用不同动作名。平移除去页面 zoom 以还原世界位移,缩放倍率本身不按 zoom 缩放。
if let Some(viewport) = viewport {
return Some(crate::events::Input {
window: self.resource,
@@ -775,6 +797,7 @@ impl TesseraApp {
screen_position: None,
});
}
// 没有局部相机时才考虑整页变换:普通 Window 不平移,只有 Canvas 接受整页缩放。
if frame.surface.mode == crate::page::Mode::Window
|| kind == "page-zoom" && frame.surface.mode != crate::page::Mode::Canvas
{
@@ -819,6 +842,7 @@ impl TesseraApp {
if frame.session == 0 || frame.fault.is_some() {
return None;
}
// 按发布帧中的挂载顺序遍历可交互目标;Shift+Tab 逆向环绕,尚无焦点时从首/末项进入。
let targets: Vec<_> = frame
.mounts
.iter()
@@ -866,6 +890,7 @@ impl TesseraApp {
if !event.repeat {
self.guest_key = self.guest_focus.clone().map(|focus| (*key, focus));
}
// 释放会取走捕获,保证不会重复激活;仅与原来按下的 Enter/Space 相同才转换成 click。
} else if let Some((pressed, mut input)) = self.guest_key.take()
&& pressed == *key
{
@@ -907,6 +932,7 @@ impl TesseraApp {
.with_titlebar_transparent(true)
.with_fullsize_content_view(true)
};
// 先创建平台窗口,再建立软件绘图上下文/表面;任何一步失败都向外返回,由 resumed 记录并处理。
let window = Arc::new(event_loop.create_window(attributes)?);
#[cfg(target_os = "macos")]
crate::native_chrome::align_controls(&window);
@@ -932,6 +958,7 @@ impl TesseraApp {
}
self.guest_pressed = None;
self.guest_key = None;
// 正常关闭先请求 VM 清理,等待 ShutdownComplete 再退出 OS 循环;发送失败时自行投递结束通知并保留错误。
if self.control.request_shutdown() {
self.exit_requested = true;
} else {
@@ -993,6 +1020,7 @@ impl TesseraApp {
let Ok(namespace) = self.namespace.read() else {
return Ok(());
};
// 读锁保护一次完整文档取样与场景生成;生成后释放锁,像素写入和 present 不再占用 namespace。
let scene = scene::build_scene(
size.width,
size.height,
@@ -1022,6 +1050,7 @@ impl TesseraApp {
LogicalSize::new(1.0, 19.0),
);
// 根据真实物理尺寸调整像素表面;逻辑坐标到物理像素的转换已由场景构造中的 scale_factor 负责。
surface
.resize(width, height)
.map_err(|error| anyhow::anyhow!("{error}"))?;
@@ -1040,8 +1069,8 @@ impl TesseraApp {
impl ApplicationHandler<AppEvent> for TesseraApp {
/// 处理 OS 直接结束事件循环的收尾,尝试在明确时限内等待运行时清理。
fn exiting(&mut self, _: &ActiveEventLoop) {
// OS termination can bypass CloseRequested. The loop is already ending;
// use a bounded fallback rather than silently dropping every callback.
// OS 终止可能跳过 CloseRequested;此时循环已在退出,无法再等事件通知。
// 最多阻塞 5 秒等待运行时完成清理,超时保留退出错误而不无限等待。
if !self.control.request_shutdown() {
self.native_error = Some("VM worker unavailable during shutdown".into());
return;
@@ -1128,6 +1157,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
let Some(window) = self.window.as_ref().cloned() else {
return;
};
// 主处理器先按 OS WindowId 转发子窗口事件;转发后汇总视口,供同一运行时布局所有窗口。
if window.id() != window_id {
if let Some(child) = self.children.values_mut().find(|child| {
child
@@ -1142,6 +1172,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
}
match event {
// 主窗口关闭触发应用退出;子窗口关闭先发 window-close,由源程序生命周期逻辑决定资源清理。
WindowEvent::CloseRequested => {
if self.resource == crate::window::MAIN_WINDOW {
self.request_exit();
@@ -1176,6 +1207,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
..
} => {
let scale = window.scale_factor() as f32;
// OS 指针是物理像素,先除 DPI 得逻辑坐标;后续侧栏命中和页面世界坐标转换都从该坐标出发。
let next = Point::new(x as f32 / scale, y as f32 / scale);
let delta = Point::new(next.x - self.pointer.x, next.y - self.pointer.y);
self.pointer = next;
@@ -1194,6 +1226,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
&& mount.camera_controls
})
});
// 3D 相机拖动超过世界坐标下的 3 单位阈值后转为 orbit,并清除 click 捕获,避免释放时误激活。
if is_3d && (point.x - start[0]).hypot(point.y - start[1]) > 3. {
self.orbiting = true;
self.panning = self.begin_pan();
@@ -1301,6 +1334,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
);
}
} else {
// 拖动结束仅结束手势,不把同一次释放再当成点击;普通点击需保持按下与释放的帧身份一致。
if self.panning {
self.panning = false;
self.guest_pressed = None;
@@ -1314,6 +1348,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
&& released.session == pressed.session
&& released.layout_version == pressed.layout_version
{
// 保留起点用于运行时判断点击/拖动语义;目标来自释放位置,具体可接受性仍由 worker 校验。
released.press_point = pressed.point;
self.send_guest(released);
}
@@ -1330,6 +1365,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
window.request_redraw();
}
WindowEvent::MouseWheel { delta, .. } => {
// 行滚动按每行 38 逻辑像素换算,像素滚动除去 DPI;Shift 在几乎没有横向分量时将纵向量转横向。
let (mut dx, mut dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => (x * 38.0, y * 38.0),
MouseScrollDelta::PixelDelta(p) => (
@@ -1418,6 +1454,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
}
window.request_redraw();
}
// 失焦可能导致收不到对应释放事件,因此清空按键/鼠标捕获、平移状态及修饰键,避免恢复焦点后卡住。
WindowEvent::Focused(false) => {
self.cancel_pointer();
self.guest_pressed = None;
+6 -1
View File
@@ -4,9 +4,10 @@
//! Clipboard text is always UTF-8, including Finder/LaunchServices launches without LANG.
use anyhow::{Result, bail};
#[cfg(target_os = "macos")]
/// 运行选定平台的剪贴板命令并检查其退出状态。
/// 构造固定 UTF-8 环境的系统命令;本函数不启动进程,退出状态由 read/write 检查。
fn command(program: &str) -> std::process::Command {
let mut cmd = std::process::Command::new(program);
// 从 Finder 等无 LANG 的环境启动时也显式指定 UTF-8,防止系统工具按其他编码转换中文。
cmd.env("LC_ALL", "en_US.UTF-8")
.env("LC_CTYPE", "en_US.UTF-8")
.env("LANG", "en_US.UTF-8");
@@ -16,6 +17,7 @@ fn command(program: &str) -> std::process::Command {
pub fn read() -> Result<String> {
#[cfg(target_os = "macos")]
{
// 直接启动固定路径程序并传递独立参数,不经过 shell;-Prefer txt 要求文本表示。
let output = command("/usr/bin/pbpaste")
.arg("-Prefer")
.arg("txt")
@@ -36,6 +38,7 @@ pub fn write(value: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
use std::io::Write;
// 正文只经子进程标准输入传递,不拼成命令参数,因此引号、换行和源码中的命令字符保持原意。
let mut child = command("/usr/bin/pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()?;
@@ -44,6 +47,7 @@ pub fn write(value: &str) -> Result<()> {
.take()
.ok_or_else(|| anyhow::anyhow!("clipboard stdin is unavailable"))?;
stdin.write_all(value.as_bytes())?;
// 写完后先关闭管道发送 EOF,让 pbcopy 知道输入结束;之后等待退出状态,不能仅以写管道成功作为完成。
drop(stdin);
if !child.wait()?.success() {
bail!("system clipboard write failed");
@@ -52,6 +56,7 @@ pub fn write(value: &str) -> Result<()> {
}
#[cfg(not(target_os = "macos"))]
{
// 非 macOS 当前没有实现适配器,显式报错而不伪装成写入成功。
let _ = value;
bail!("system clipboard adapter is not available on this platform")
}
+43 -3
View File
@@ -104,7 +104,7 @@ pub struct Workspace {
active: usize,
}
impl Workspace {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
/// 为 Console/Sources/Network/Settings 各创建一份独立状态,共享事件循环代理,初始选择 Console。
pub fn new(proxy: winit::event_loop::EventLoopProxy<AppEvent>) -> Self {
let states = [Tab::Console, Tab::Sources, Tab::Network, Tab::Settings].map(|tab| {
DeveloperToolsState {
@@ -123,6 +123,7 @@ impl Workspace {
Some("Settings") => 3,
_ => 0,
};
// 每个工具保留自己的文本/滚动/选择,强调色在切换时从旧活动页复制;切走先 blur 解除捕获与输入法状态。
let accent = self.states[self.active].accent;
if next != self.active {
self.states[self.active].blur();
@@ -160,7 +161,9 @@ pub struct DeveloperToolsState {
pub selected_node_id: Option<String>,
pub focus: Option<Focus>,
pub input: String,
// Console 光标是 UTF-8 字节位置,移动和删除按 grapheme 边界维护,不用字符下标直接切字符串。
pub cursor: usize,
// 输入框表示全选状态,Sources 则结合 SourceSelection 表示全选;并非通用起止字节范围。
pub selection: bool,
pub preedit: String,
pub filter: String,
@@ -170,6 +173,7 @@ pub struct DeveloperToolsState {
tree_width: f32,
network_fraction: f32,
scroll: [Scroll; 5],
// 各 Region 独立保存行文本指纹与最大宽度,颜色/选择改变无需重测,文本改变才失效。
widths: [Option<(u64, f32)>; 5],
reveal_input: bool,
console_stamp: u64,
@@ -177,10 +181,12 @@ pub struct DeveloperToolsState {
cloud: bool,
follow: bool,
selected_request: Option<u64>,
// 清屏水位只隐藏旧记录,与共享 ActivityLog 的实际淘汰分开;Network 有独立水位。
console_after: u64,
network_after: u64,
history: Vec<String>,
history_index: Option<usize>,
// 首次进入历史导航前保存当前未提交输入,向下越过最新历史后恢复此草稿。
draft: String,
}
impl Default for DeveloperToolsState {
@@ -237,6 +243,7 @@ pub struct DeveloperToolsLayout {
impl DeveloperToolsLayout {
/// 将工具局部绘制/交互布局平移到实际窗口位置。
pub fn translate(&mut self, dx: f32, dy: f32) {
// 绘制列表已由外层按设备像素平移,这里所有命中矩形和文字原点都按逻辑单位同步移动。
self.panel.x += dx;
self.panel.y += dy;
for target in &mut self.widgets {
@@ -278,6 +285,7 @@ struct ConsoleInputLayout {
impl DeveloperToolsState {
/// 用最新完整源文档更新工具投影,维护 Sources 与 Console 历史的一致性。
pub fn document_updated(&mut self, doc: &RenderDocument) {
// 已选 AST ID 在新文档不存在时回退根;有效选择继续保留,不按行号盲目关联另一份代码。
if self
.selected_node_id
.as_deref()
@@ -325,6 +333,7 @@ impl DeveloperToolsState {
if !self.open || !layout.panel.contains(p) {
return;
}
// 鼠标重新定位输入目标时结束旧预编辑显示,避免把输入法组合文本残留到另一控件。
self.preedit.clear();
self.pointer = p;
self.interaction.pointer_moved(p, &layout.widgets);
@@ -332,6 +341,7 @@ impl DeveloperToolsState {
self.focus = None;
if let Some((area, origin, lines)) = &layout.source
&& area.contains(p)
// 只有未被滚动条/分隔条等控件命中时才开始源码拖选,管理控件优先于文字区域。
&& target.is_none()
{
self.focus = Some(Focus::Source);
@@ -354,6 +364,7 @@ impl DeveloperToolsState {
self.drag = Some(Drag::Scroll(*region, *h, *ratio));
}
}
// 文本框按下立即取得编辑焦点/定位光标;普通按钮动作仍等 release 确认同一启用目标。
Role::TextField => self.activate(target.action, layout, p, activity),
_ => {}
}
@@ -375,6 +386,7 @@ impl DeveloperToolsState {
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);
let mut start = 0;
@@ -405,6 +417,7 @@ impl DeveloperToolsState {
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();
@@ -431,6 +444,7 @@ impl DeveloperToolsState {
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 {
@@ -473,6 +487,7 @@ impl DeveloperToolsState {
event::ElementState,
keyboard::{Key, NamedKey},
};
// 输入法正在组合时不抢 Enter/Space 等控件键盘激活,避免确认候选被误触发按钮。
if !self.open || !self.preedit.is_empty() {
return false;
}
@@ -482,6 +497,7 @@ impl DeveloperToolsState {
}
return true;
}
// Console 输入内普通 Tab 插四空格;Shift+Tab 或其他焦点的 Tab 走控件循环导航。
if event.logical_key == Key::Named(NamedKey::Tab)
&& (self.focus != Some(Focus::Input) || shift)
{
@@ -509,6 +525,7 @@ impl DeveloperToolsState {
)
{
if event.state == ElementState::Pressed {
// 控件只在非自动重复按下时捕获,抬起时返回动作,避免长按 Enter 重复提交控制操作。
if !event.repeat {
self.interaction.key_press(&layout.widgets);
}
@@ -533,6 +550,7 @@ impl DeveloperToolsState {
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 =
@@ -551,6 +569,7 @@ impl DeveloperToolsState {
/ (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 {
@@ -567,6 +586,7 @@ impl DeveloperToolsState {
}
/// 将滚轮作用到当前工具的可滚动区域,钳制本工具偏移。
pub fn wheel(&mut self, layout: &DeveloperToolsLayout, p: Point, dx: f32, dy: f32) {
// 滚轮寻找最前景包含点的 Region,只钳下限;上限在下一轮绘制根据最新内容范围统一钳制。
if let Some((_, r, _, _)) = layout
.regions
.iter()
@@ -585,6 +605,7 @@ impl DeveloperToolsState {
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;
@@ -596,6 +617,7 @@ impl DeveloperToolsState {
}
self.input.insert_str(self.cursor, value);
self.cursor += value.len();
// 编辑历史条目后退出历史导航,当前输入成为新的可提交文本;光标使用插入文本实际 UTF-8 字节数推进。
self.history_index = None;
self.reveal_cursor();
}
@@ -605,6 +627,7 @@ impl DeveloperToolsState {
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;
}
@@ -616,6 +639,7 @@ impl DeveloperToolsState {
self.custom.extend(
value
.chars()
// 颜色输入仅保留 hex/# 字符,每次最多7个;真正六位格式在 Enter 应用时验证。
.filter(|c| c.is_ascii_hexdigit() || *c == '#')
.take(7),
);
@@ -624,6 +648,7 @@ impl DeveloperToolsState {
}
}
/// 调整输入区滚动位置,让当前光标保持在可见范围内。
// 只设置下一帧 reveal 标记,具体滚动到可见范围由 Console build 在测量光标位置后完成。
pub(crate) fn reveal_cursor(&mut self) {
self.reveal_input = true;
}
@@ -644,6 +669,7 @@ impl DeveloperToolsState {
}
/// 取得当前工具文本选择内容,供复制命令使用。
pub fn selected_text(&self) -> String {
// 源码支持任意拖选区间或全选;其他三个文本框当前只支持整框全选复制。
if self.focus == Some(Focus::Source) {
return self.source_selection.text(self.selection);
}
@@ -674,6 +700,7 @@ impl DeveloperToolsState {
}
return;
}
// 应用主题时解析六位 RGB 并调整过亮强调色,失败保留原主题并写输入错误。
if self.focus == Some(Focus::Custom) && *key == Enter {
let hex = self.custom.trim_start_matches('#');
if hex.len() == 6
@@ -724,6 +751,7 @@ impl DeveloperToolsState {
.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() {
@@ -776,10 +804,12 @@ impl DeveloperToolsState {
};
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();
}
@@ -797,6 +827,7 @@ impl DeveloperToolsState {
}
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();
let target = if *key == ArrowUp {
if start == 0 {
@@ -828,11 +859,12 @@ impl DeveloperToolsState {
}
self.reveal_cursor();
}
/// 初始化内置工具对应的页面和展示状态,避免重复建立同一组工具。
/// 在线程中经本地 REPL 请求打开指定内置工具,页面复用/创建由 VM 的 developerTools 实现负责。
pub fn initialize_tools(&mut self, name: &str) {
let name = name.to_owned();
let proxy = self.proxy.clone();
thread::spawn(move || {
// 用调试字符串转义名称生成 Endor 文本,再经本地 REPL 执行;实际工具白名单由宿主能力验证。
let source = format!("ui.developerTools({name:?})");
let send = || -> std::io::Result<()> {
let mut stream = TcpStream::connect_timeout(
@@ -875,6 +907,8 @@ impl DeveloperToolsState {
self.input.clone()
};
let proxy = self.proxy.clone();
// 后台仅发送一次当前输入或 DELETE 重置;源通过正文传递,传输失败带回原 source 让 GUI 恢复输入。
// 线程成功启动不等于源执行成功,HTTP 结果由 ActivityLog 在 Console 展示。
let spawned=thread::Builder::new().name("tessera-console-client".into()).spawn(move||{
let send=||->std::io::Result<()> {
let mut stream=TcpStream::connect_timeout(&"127.0.0.1:4737".parse().unwrap(),Duration::from_secs(2))?;
@@ -886,6 +920,7 @@ impl DeveloperToolsState {
if let Err(error)=send()&&let Some(proxy)=proxy{let _=proxy.send_event(AppEvent::ConsoleFailed{message:error.to_string(),source});}
});
match spawned {
// 发送线程启动成功才清输入并加入最多80条历史;之后若网络失败由 ConsoleFailed 反馈,不能把清框当执行成功。
Ok(_) => {
if !reset {
self.history.push(self.input.clone());
@@ -906,7 +941,7 @@ impl DeveloperToolsState {
}
}
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
/// 按当前工具生成设备比例显示列表与逻辑坐标命中布局,绘制后校正跨帧焦点和捕获。
pub fn build(
width: u32,
height: u32,
@@ -918,6 +953,7 @@ pub fn build(
if !state.open {
return (Default::default(), Default::default());
}
// 布局和命中统一用逻辑像素,最后仅显示列表按设备 scale 转换,返回 layout 仍为逻辑坐标。
let width = width as f32 / scale;
let height = height as f32 / scale;
let w = width;
@@ -946,6 +982,7 @@ pub fn build(
MUTED,
false,
);
// 锁内只克隆最多80条活动记录,接下来的文字排版/测量不持日志锁,HTTP worker 可继续更新。
let records = activity
.lock()
.unwrap()
@@ -985,6 +1022,7 @@ pub fn build(
);
// 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.
// 分隔条最后绘制/注册,命中优先级与可见叠放相同,细线扩展命中区不会被后画的背景遮住。
for (rect, kind) in p.layout.splits.clone().into_iter().rev() {
p.ui.splitter(
rect,
@@ -996,6 +1034,7 @@ pub fn build(
);
}
p.ui.list.push(DrawCommand::PopClip);
// 把本管理 UI 当前使用的矩形、文字、裁剪、描边逐一转设备比例,几何与文字样式同倍率缩放。
let mut scaled = DisplayList::default();
for cmd in p.ui.list.commands() {
let rect =
@@ -1037,6 +1076,7 @@ pub fn build(
});
}
p.layout.widgets = p.ui.targets;
// 根据新目标集合清理失效焦点/捕获,若视觉状态变化要求再绘一帧,避免控件消失后保持旧按压外观。
state.repaint = state.interaction.reconcile(&p.layout.widgets);
(scaled, p.layout)
}
+17 -1
View File
@@ -3,7 +3,7 @@
//!
use super::*;
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
/// 将有界 HTTP 活动、当前编辑输入和输入法预编辑投影为 Console 显示行与命中布局,并按需滚动到光标。
pub(super) fn build(
p: &mut Painter,
state: &mut DeveloperToolsState,
@@ -12,6 +12,7 @@ pub(super) fn build(
) {
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);
@@ -44,7 +45,9 @@ pub(super) fn build(
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")
@@ -63,6 +66,7 @@ pub(super) fn build(
} 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(
@@ -73,6 +77,7 @@ pub(super) fn build(
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 { " " }),
@@ -84,6 +89,7 @@ pub(super) fn build(
} else {
OK
};
// 未完成请求显示 Executing,完成后才解析响应;HTTP 4xx/5xx 使用错误颜色。
if let Some(status) = r.status {
if !local {
lines.push(Line::new(
@@ -107,6 +113,7 @@ pub(super) fn build(
);
}
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);
@@ -114,8 +121,10 @@ pub(super) fn build(
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);
@@ -126,13 +135,16 @@ pub(super) fn build(
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(
@@ -148,6 +160,7 @@ pub(super) fn build(
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,
@@ -155,6 +168,7 @@ pub(super) fn build(
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(
@@ -166,6 +180,7 @@ pub(super) fn build(
Action::Focus(Focus::Input),
);
}
// 光标单独绘制并限制在文本视口内,不能穿过滚动条或工具页边缘;随后配对 PopClip。
if state.focus == Some(Focus::Input) {
p.ui.list.push(DrawCommand::PushClip(Rect::new(
area.x,
@@ -184,6 +199,7 @@ pub(super) fn build(
);
p.ui.list.push(DrawCommand::PopClip);
}
// 底部无输入错误时展示快捷键,有错则复用同一位置显示错误文本及错误颜色。
p.label(
x + 10.0,
bottom - 23.0,
+8 -1
View File
@@ -3,7 +3,7 @@
//!
use super::*;
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
/// 将本进程 HTTP 活动投影为可筛选请求列表和独立滚动详情,并生成选择/分隔条命中区域。
pub(super) fn build(
p: &mut Painter,
state: &mut DeveloperToolsState,
@@ -50,10 +50,13 @@ pub(super) fn build(
MUTED,
true,
);
// 上下区分隔位置取自工具状态比例,拖动只改变显示分配,不改变记录本身。
let divider = 92.0 + (bottom - 92.0) * state.network_fraction;
// 对方法、路径和状态的组合字符串做大小写不敏感子串匹配;未响应记录可用 pending 搜索。
let filter = state.filter.to_lowercase();
let visible = records
.iter()
// 清屏按 ID 水位隐藏旧记录,不删除共享 ActivityLog,Console 仍可独立展示同一条请求。
.filter(|r| r.id > state.network_after)
.filter(|r| {
format!(
@@ -82,10 +85,12 @@ pub(super) fn build(
TEXT
},
);
// 请求 ID 是选择身份,不依赖筛选后行号;点击行仅生成 Request 动作,不重新发送 HTTP。
l.selected = state.selected_request == Some(r.id);
l.action = Some(Action::Request(r.id));
lines.push(l);
}
// 空列表可能来自无记录、清屏或过滤无匹配,统一展示本进程 API 的捕获范围提示。
if lines.is_empty() {
lines.push(Line::new(
"No matching requests. Listening to Tessera's local API.",
@@ -100,6 +105,7 @@ pub(super) fn build(
false,
);
p.split(Rect::new(x + 1.0, divider, w - 2.0, 1.0), Split::Network);
// 详情从完整保留记录中查找,因此筛选隐藏某行后可继续查看已选详情;记录淘汰后才回提示。
let selected = records
.iter()
.find(|r| Some(r.id) == state.selected_request);
@@ -113,6 +119,7 @@ pub(super) fn build(
MUTED,
);
}
// 请求列表与详情使用不同 Region,各自维护滚动量,避免切换详情影响列表定位。
p.lines(
Rect::new(x + 1.0, divider + 5.0, w - 2.0, bottom - divider - 5.0),
Region::Details,
+29 -9
View File
@@ -9,16 +9,17 @@ pub(super) struct Painter {
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,
@@ -26,17 +27,18 @@ impl Painter {
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,
@@ -45,17 +47,20 @@ impl Painter {
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)
@@ -65,18 +70,21 @@ impl Painter {
}
};
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()
@@ -92,6 +100,7 @@ impl Painter {
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) {
@@ -109,6 +118,7 @@ impl Painter {
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,
@@ -120,6 +130,7 @@ impl Painter {
} 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(
@@ -136,13 +147,14 @@ impl Painter {
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,
@@ -151,13 +163,16 @@ impl Painter {
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)
@@ -177,6 +192,7 @@ impl Painter {
);
self.layout
.thumbs
// 保存每单位手柄拖动对应的内容位移倍率,事件处理可直接反算滚动量。
.push((thumb, region, h, maximum / travel));
}
}
@@ -196,7 +212,7 @@ pub(super) enum Divider {
Neutral,
}
impl Line {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
/// 创建默认无分隔线、未选中且无点击动作的显示行。
pub(super) fn new(value: impl Into<String>, color: Color) -> Self {
Self {
value: value.into(),
@@ -207,7 +223,7 @@ impl Line {
}
}
}
/// 按行绘制工具文本,返回后续布局需要的占用范围。
/// 将文本按换行分成显示行并展开 Tab,追加到 out;不直接绘制,也不返回布局范围。
pub(super) fn multiline(out: &mut Vec<Line>, value: &str, color: Color) {
out.extend(
value
@@ -218,7 +234,9 @@ pub(super) fn multiline(out: &mut Vec<Line>, value: &str, color: Color) {
}
/// 把一条 Console 结果投影为可滚动、可选择的文字画面。
pub(super) fn console_result(out: &mut Vec<Line>, body: &str, color: Color) {
// 响应若为 JSON,按 reset、value、diagnostics、error、revision 优先提取可读结果;不满足时退回原文。
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
// 先显示 REPL 是否发布新页面或仅保留内存状态,再显示实际结果/诊断,避免把成功执行等同于重绘。
if value["execution"] == "repl" {
out.push(Line::new(
if value["rendered"] == true {
@@ -281,6 +299,7 @@ pub(super) fn console_result(out: &mut Vec<Line>, body: &str, color: 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);
@@ -303,6 +322,7 @@ pub(super) fn request_details(out: &mut Vec<Line>, r: &Exchange) {
),
TEXT,
);
// 固定次序展示请求头、请求正文、响应头、响应正文;这里只消费已经截断的观察记录。
for (title, body) in [
("REQUEST HEADERS", &r.request_headers),
("REQUEST BODY", &r.request),
+13 -1
View File
@@ -12,13 +12,15 @@ use unicode_segmentation::UnicodeSegmentation;
/// 行文本、锚点与选择端点,既供鼠标拖选也供键盘导航使用。
pub(super) struct SourceSelection {
lines: Vec<String>,
// 端点是 (行号, UTF-8 字节偏移),字节位置始终由完整字素边界产生,不用字符数直接切片。
anchor: (usize, usize),
end: (usize, usize),
pub dragging: bool,
}
impl SourceSelection {
/// 替换可选择文本行,并把旧选择限制在新文本仍有效的位置。
/// 同步展示文本;内容变化时重置选择和拖动,内容完全相同时保留原选区。
pub fn set_lines(&mut self, lines: &[String]) {
// 文本变化时重置两端与拖选状态;当前实现不是把旧选区钳到新文本,避免对应内容已经改变。
if self.lines != lines {
self.lines = lines.to_vec();
self.anchor = (0, 0);
@@ -28,6 +30,7 @@ impl SourceSelection {
}
/// 把工具指针位置换算成合法行号与文本字符边界。
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));
let Some(line) = lines.get(row) else {
@@ -35,6 +38,7 @@ impl SourceSelection {
};
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;
@@ -47,6 +51,7 @@ impl SourceSelection {
}
/// 在指针位置建立文本选择锚点,开始一次拖动选择。
pub fn begin(&mut self, p: Point, origin: Point, lines: &[String]) {
// 调用方提供的 lines 与当前展示一致;开始拖动先折叠旧选择,后续 extend 只移动活动端。
self.anchor = Self::locate(p, origin, lines);
self.end = self.anchor;
self.dragging = true;
@@ -66,6 +71,7 @@ impl SourceSelection {
let (mut row, mut byte) = self.end;
let line = self.lines.get(row)?;
match key {
// 水平移动按字素跨越,组合音标和 emoji 视作整体;到行边界后跨到相邻行的末尾/开头。
ArrowLeft => {
if byte > 0 {
byte = line[..byte]
@@ -85,6 +91,7 @@ impl SourceSelection {
byte = 0;
}
}
// 垂直移动保持的是字素列数,不是像素 x;目标行较短时取尽其字素,落到行尾。
ArrowUp | ArrowDown => {
let col = line[..byte].graphemes(true).count();
row = if *key == ArrowUp {
@@ -98,6 +105,7 @@ impl SourceSelection {
.map(str::len)
.sum();
}
// 普通 Home/End 选本行边界,command 修饰时转到整个文本首/末行。
Home => {
if command {
row = 0;
@@ -113,6 +121,7 @@ impl SourceSelection {
_ => return None,
}
self.end = (row, byte);
// 未按 Shift 时同步锚点以折叠选区;按 Shift 则仅移动 end,保持原锚点扩展选择。
if !shift {
self.anchor = self.end;
}
@@ -129,6 +138,7 @@ impl SourceSelection {
),
);
}
// 元组字典序先比行号再比字节位置,统一处理从后向前拖选与从前向后拖选。
(self.anchor.min(self.end), self.anchor.max(self.end))
}
/// 拼接当前选择范围内的实际文本,保持多行换行与 UTF-8 边界。
@@ -139,6 +149,7 @@ impl SourceSelection {
.enumerate()
.filter(|(i, _)| *i >= a.0 && *i <= b.0)
.map(|(i, line)| {
// 首尾行只取选中片段,中间行取完整文本;用安全 get 防止不一致范围导致切片 panic。
let start = if i == a.0 { a.1 } else { 0 };
let end = if i == b.0 { b.1 } else { line.len() };
line.get(start..end).unwrap_or("").to_owned()
@@ -149,6 +160,7 @@ impl SourceSelection {
/// 取得某行的可见选择片段,供高亮绘制复用。
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;
}
+5 -1
View File
@@ -3,7 +3,7 @@
//!
use super::*;
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
/// 绘制强调色候选、自定义颜色编辑框及主题预览;实际应用和错误校验由输入动作处理。
pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport: Rect) {
let x = viewport.x;
let w = viewport.width;
@@ -16,6 +16,7 @@ pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport:
MUTED,
false,
);
// 色块动作携带调色板索引,绘制仅比较当前 accent 标记选中;实际修改由统一动作处理器执行。
for (i, color) in PALETTE.iter().enumerate() {
let r = Rect::new(x + 24.0 + i as f32 * 44.0, 122.0, 32.0, 32.0);
p.ui.swatch(r, Action::Theme(i), *color, *color == state.accent);
@@ -27,6 +28,7 @@ pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport:
MUTED,
false,
);
// 自定义颜色框显示尚未应用的编辑文本;聚焦只改变外观和输入接收位置,不在这里解析 hex。
let custom = Rect::new(x + 24.0, 204.0, 200.0, 32.0);
p.ui.text_field(
custom,
@@ -49,11 +51,13 @@ pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport:
true,
);
// 校验错误由输入处理路径写入,设置页只显示;错误不会在每次重绘时重复执行或清除。
if !state.input_error.is_empty() {
p.label(x + 24.0, 245.0, &state.input_error, ERROR, false);
}
p.fill(Rect::new(x + 24.0, 285.0, w - 48.0, 1.0), BORDER);
p.label(x + 24.0, 305.0, "Theme preview", TEXT, false);
// 预览复用当前强调色和标准成功色,让用户看到主题如何影响执行分隔线;示例文字不送入 VM。
p.fill(Rect::new(x + 24.0, 340.0, w - 48.0, 2.0), state.accent);
p.label(x + 32.0, 353.0, "› \"Hello\"", TEXT, true);
p.label(x + 32.0, 380.0, "‹ \"Hello\"", OK, true);
+13 -1
View File
@@ -3,7 +3,7 @@
//!
use super::*;
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
/// 根据云文件/当前快照模式构建左右两栏,展示文件或 AST 选择,并生成只读源码行和跳转动作。
pub(super) fn build(
p: &mut Painter,
state: &mut DeveloperToolsState,
@@ -12,6 +12,7 @@ pub(super) fn build(
) {
let x = viewport.x;
let w = viewport.width;
// width 在本函数表示右边界坐标,w 才是视口宽度;用于计算右侧源码区域和按钮位置。
let width = viewport.x + viewport.width;
let bottom = viewport.height - 22.0;
@@ -28,6 +29,7 @@ pub(super) fn build(
Action::Cloud,
state.cloud,
);
// 两种数据源分开投影:云模式用 provider 的 files/opened,快照模式只读已发布文档 source/root。
if state.cloud {
p.button(
Rect::new(width - 92.0, 32.0, 88.0, 29.0),
@@ -35,6 +37,7 @@ pub(super) fn build(
Action::CloudRefresh,
false,
);
// 树宽至少 120,通常为右侧内容保留 180;窄窗口仍保持树最小宽,交由绘制裁剪处理。
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, "CLOUD FILES", MUTED, false);
@@ -44,6 +47,7 @@ pub(super) fn build(
.iter()
.map(|file| {
let mut l = Line::new(&file.path, TEXT);
// 动作传递 provider 的不透明文件 ID,显示路径不参与本地磁盘读取或代码执行。
l.action = Some(Action::CloudOpen(file.id.clone()));
l
})
@@ -57,6 +61,7 @@ pub(super) fn build(
);
p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree);
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);
for (i, line) in source.code.split('\n').enumerate() {
@@ -69,6 +74,7 @@ pub(super) fn build(
MUTED,
);
}
// provider 错误附在当前源码下方;即使读取失败仍可展示上次成功打开的内容。
if !state.cloud_sources.error.is_empty() {
source_lines.push(Line::new(&state.cloud_sources.error, ERROR));
}
@@ -91,6 +97,7 @@ pub(super) fn build(
if doc
.root
.as_ref()
// REPL 文档展示为交互记录,普通脚本展示为 app.main.endor;仅改变标题,不合成另一份源码。
.is_some_and(|root| root.kind == "repl-session")
{
"Console transcript"
@@ -105,6 +112,7 @@ pub(super) fn build(
let mut tree_lines = Vec::new();
if let Some(root) = &doc.root {
let mut flat = Vec::new();
// 将发布 AST 展平成带深度的行;行动作保存节点稳定 ID 与起始行,用于选择并跳到对应代码。
flatten(root, 0, &mut flat);
for (depth, n) in flat {
let mut l = Line::new(format!("{}{} {}", " ".repeat(depth), n.kind, n.name), TEXT);
@@ -123,23 +131,27 @@ pub(super) fn build(
false,
);
p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree);
// 每次重绘按当前文档查节点,旧选择 ID 若已不存在则不高亮,避免沿用旧 AST 行范围。
let selected = state
.selected_node_id
.as_deref()
.and_then(|id| doc.node(id));
let code_lines = doc
.source
// 仅显示投影统一 CRLF 并把 Tab 展开为四空格;原始文档文本保持不变。
.replace("\r\n", "\n")
.split('\n')
.enumerate()
.map(|(i, s)| {
let mut l = Line::new(format!("{:>5} {}", i + 1, s.replace('\t', " ")), TEXT);
// i 是零基行号,AST 行号是一基;条件等价于 start_line <= i+1 <= end_line。
l.selected = selected.is_some_and(|n| i + 1 >= n.start_line && i < n.end_line);
l
})
.collect::<Vec<_>>();
p.lines(
Rect::new(code_x, 92.0, width - code_x, bottom - 92.0),
// 源码区与 AST 树各自保存滚动位置,选择节点并不把树文本误当作源码内容。
Region::Source,
&code_lines,
state,
+10 -1
View File
@@ -1,13 +1,22 @@
//! 原生宿主模块入口。窗口、管理控件、系统适配和开发工具状态与用户业务界面分开维护。
//!
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 保存运行活动、请求和诊断记录,供宿主开发工具展示。
pub(crate) mod activity;
/// 实现 winit 应用生命周期、窗口协调、输入路由和 softbuffer 像素呈现。
pub(crate) mod app;
/// 调用平台剪贴板工具读写文本,向编辑器返回明确错误。
pub(crate) mod clipboard;
/// 组合控制台、源码、网络和设置工具的状态、交互与绘制。
pub(crate) mod developer_tools;
#[cfg(target_os = "macos")]
/// 仅在 macOS 编译,调整原生交通灯按钮并处理标题栏平台行为。
pub(crate) mod native_chrome;
/// 维护页面/工具入口列表、折叠宽度和命中目标;可用页面宽度需扣除其占用。
pub(crate) mod sidebar;
/// 从源码与诊断生成可浏览的结构信息,供 Sources 工具定位对象和文本。
pub(crate) mod sources;
/// 提供宿主管理界面专用的主题、控件和交互状态,和 Endor 业务界面共享底层绘图能力。
pub(crate) mod system_ui;
/// 提供页面标签投影及相关数据处理,页面业务身份仍由运行时维护。
pub(crate) mod tabs;
+7
View File
@@ -13,6 +13,7 @@ use winit::{
/// action automatically. Use the native click count and native window actions.
/// 读取系统偏好的标题栏双击行为,委托原生窗口执行相应动作。
pub fn titlebar_double_click(window: &Window) -> bool {
// AppKit 必须在主线程调用;不在主线程、无有效句柄或句柄非 AppKit 时返回未处理。
let Some(main_thread) = MainThreadMarker::new() else {
return false;
};
@@ -28,6 +29,7 @@ pub fn titlebar_double_click(window: &Window) -> bool {
let Some(event) = NSApplication::sharedApplication(main_thread).currentEvent() else {
return false;
};
// 依赖系统原生连击计数,不自行推算两次输入时间,保持用户设置与系统行为一致。
if event.clickCount() != 2 {
return false;
}
@@ -35,12 +37,14 @@ pub fn titlebar_double_click(window: &Window) -> bool {
let Some(native) = view.window() else {
return false;
};
// 全屏时把双击视作已处理,但不执行缩放/最小化,避免自绘标题行改变全屏状态。
if native.styleMask().contains(NSWindowStyleMask::FullScreen) {
return true;
}
let action = NSUserDefaults::standardUserDefaults()
.stringForKey(&NSString::from_str("AppleActionOnDoubleClick"))
.map(|value| value.to_string());
// 按系统偏好执行动作;缺省采用 Zoom,禁用或未知选项不动作,但仍返回已处理。
match action.as_deref() {
Some("Minimize") => native.performMiniaturize(None),
None | Some("Maximize" | "Fill") => native.performZoom(None),
@@ -74,6 +78,7 @@ pub fn align_controls(window: &Window) {
}
let bounds = view.bounds();
let center = f64::from(crate::sidebar::HEADER) / 2.0;
// AppKit 视图可能以左下或左上为原点;先在内容视图内算标题行中心,再转到按钮父视图。
let content_y = if view.isFlipped() {
bounds.origin.y + center
} else {
@@ -90,10 +95,12 @@ pub fn align_controls(window: &Window) {
let Some(parent) = button.superview() else {
continue;
};
// 按钮可能位于不同 AppKit 容器,必须使用 convertPoint 而不能直接赋内容视图坐标。
let target = parent.convertPoint_fromView(NSPoint::new(0.0, content_y), Some(view));
let frame = button.frame();
let y = target.y - frame.size.height / 2.0;
// Preserve native button size, horizontal spacing and behavior.
// 只改变足够明显的纵向偏差,保留系统按钮横向间距、尺寸与原生动作,避免反复微调布局。
if (frame.origin.y - y).abs() > 0.01 {
button.setFrameOrigin(NSPoint::new(frame.origin.x, y));
}
+15
View File
@@ -13,8 +13,10 @@ use crate::{
tabs::Tab,
};
// 展开侧栏固定逻辑宽度;实际设备像素比例由后续显示列表缩放处理。
pub const WIDTH: f32 = 256.0;
pub const HEADER: f32 = 40.0;
// macOS 标题行预留原生三键安全区域,其他平台无需这段横向留白。
pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 };
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -32,6 +34,7 @@ pub struct State {
pub open: bool,
pub interaction: Interaction,
pub targets: Vec<Target<Action>>,
// 只滚动普通页面列表,顶部工具入口、折叠按钮和底部工作区标签保持固定。
pub scroll: f32,
}
@@ -55,6 +58,8 @@ impl State {
/// 判断指针是否落入宿主侧栏/展开按钮区域,保护页面输入边界。
pub fn covers(&self, point: Point) -> bool {
// 展开时整个侧栏横向带均拦截页面输入,y 的窗口有效范围由外层事件入口保证。
// 折叠时只覆盖顶部三键安全区与展开按钮,不继续挡住左侧整列页面。
if self.open {
point.x >= 0.0 && point.x < WIDTH
} else {
@@ -69,10 +74,12 @@ impl State {
height: f32,
accent: crate::display_list::Color,
) -> DisplayList {
// 每帧重新生成控件目标与显示列表,保留 Interaction 的稳定身份状态,而不是复用上帧屏幕矩形。
let mut ui = Ui::new(Theme::new(accent), self.interaction.clone());
if self.open {
ui.fill(Rect::new(0.0, 0.0, WIDTH, height), theme::BAR);
}
// 展开时按钮靠侧栏右边,折叠时靠原生按钮安全区右侧,给页面释放左边宽度。
let toggle_x = if self.open {
WIDTH - 40.0
} else {
@@ -107,6 +114,7 @@ impl State {
.into_iter()
.enumerate()
{
// 工具入口的选中状态来自已发布标签,而非仅凭最后点击动作推断,避免请求失败后显示错误选中。
let selected = tabs
.iter()
.any(|tab| tab.active && tab.developer_tool.as_deref() == Some(name));
@@ -118,10 +126,12 @@ impl State {
}
ui.label(20.0, 303.0, "页面", theme::MUTED, false);
let area = Rect::new(8.0, 333.0, WIDTH - 16.0, (height - 383.0).max(0.0));
// 开发工具已由固定入口展示,因此页面列表仅收集普通页,保持原注册表顺序。
let pages: Vec<_> = tabs
.iter()
.filter(|tab| tab.developer_tool.is_none())
.collect();
// 页面关闭或窗口增高后重新钳制旧偏移,内容不足视口时回零,防止列表留在空白区域。
self.scroll = self
.scroll
.clamp(0.0, (pages.len() as f32 * 36.0 - area.height).max(0.0));
@@ -129,11 +139,13 @@ impl State {
for (index, tab) in pages.into_iter().enumerate() {
let y = area.y + index as f32 * 36.0 - self.scroll;
// Register only fully visible rows: paint clipping must match input.
// 当前实现完全跳过部分可见行,绘制与注册都不发生;Clip 仍保护其他原语不溢出列表区域。
if y < area.y || y + 32.0 > area.y + area.height {
continue;
}
ui.button(
Rect::new(12.0, y, WIDTH - 56.0, 32.0),
// 切换和关闭动作携带稳定页面 ID,滚动或标题变化不会改变动作身份。
Action::Activate(tab.id),
Button::new(&tab.title).selected(tab.active),
);
@@ -146,6 +158,7 @@ impl State {
ui.list.push(DrawCommand::PopClip);
ui.label(20.0, height - 32.0, "本地工作区", theme::MUTED, false);
}
// 重建目标后校正跨帧捕获/焦点;折叠会移除旧页面和工具目标,不能在释放时激活已隐藏控件。
self.targets = ui.targets;
self.interaction.reconcile(&self.targets);
ui.list
@@ -156,12 +169,14 @@ impl State {
mod tests {
use super::*;
#[test]
/// 先展开绘制再折叠重绘,检查交互目标只剩 Toggle、页面左侧让宽归零,其他区域不再拦截指针。
fn collapsing_removes_every_host_target_except_the_toggle() {
let mut sidebar = State::default();
sidebar.draw(&[], 800.0, theme::PALETTE[0]);
assert!(sidebar.targets.len() > 1);
sidebar.open = false;
sidebar.draw(&[], 800.0, theme::PALETTE[0]);
// 折叠后仅保留 Toggle,且页面不再让出左宽;顶部按钮区域之外的指针不被侧栏拦截。
assert_eq!(sidebar.targets.len(), 1);
assert_eq!(sidebar.targets[0].action, Some(Action::Toggle));
assert_eq!(sidebar.left(), 0.0);
+9 -1
View File
@@ -8,7 +8,9 @@ use anyhow::Result;
#[derive(Clone, Debug)]
/// provider 提供的一条文件元数据,供云文件列表显示。
pub struct SourceFile {
// provider 定义的不透明文件身份,后续 open_file 使用;显示路径不必等于该身份。
pub id: String,
// 面向用户显示的路径文本,不能据此推断为本机可直接读取的文件路径。
pub path: String,
}
#[derive(Clone, Debug)]
@@ -19,8 +21,9 @@ pub struct OpenedSource {
}
/// 外部文件能力接口;只负责列出和读取,不与 VM 全局环境耦合。
// 由宿主注入具体实现;Send 允许转移线程,但方法使用 &mut self,不声明可并行共享调用。
pub trait CloudSourceProvider: std::fmt::Debug + Send {
/// 通过当前 provider 请求文件列表;默认未连接实现返回明确不可用状态。
/// 返回 provider 当前可列出的文件;未连接状态由 SourceWorkspace 的 Option 表达。
fn list_files(&mut self) -> Result<Vec<SourceFile>>;
/// 通过 provider 取得指定文件内容,不在工具查看动作中执行该内容。
fn open_file(&mut self, id: &str) -> Result<OpenedSource>;
@@ -28,6 +31,7 @@ pub trait CloudSourceProvider: std::fmt::Debug + Send {
#[derive(Debug, Default)]
/// Sources 云文件页面保留的 provider、列表、选择、内容与错误状态。
pub struct SourceWorkspace {
// None 表示未接入外部文件服务;默认工作区不提供虚构文件或隐式认证。
pub provider: Option<Box<dyn CloudSourceProvider>>,
pub files: Vec<SourceFile>,
pub opened: Option<OpenedSource>,
@@ -36,10 +40,12 @@ pub struct SourceWorkspace {
impl SourceWorkspace {
/// 更新云文件列表并保存结果或错误,供 Sources 页面显示。
pub fn refresh(&mut self) {
// 未连接时明确写错误并返回;已有文件列表/打开内容不会在失败路径中被清除。
let Some(provider) = &mut self.provider else {
self.error = "No cloud file provider configured".into();
return;
};
// 调用是同步的,调用方负责安排执行位置;成功才整体替换列表,失败保留旧列表供查看。
match provider.list_files() {
Ok(files) => {
self.files = files;
@@ -49,10 +55,12 @@ impl SourceWorkspace {
}
}
/// 更新选中文件的内容与打开状态,失败保持可读错误信息。
// 只读取内容而不执行代码;未连接时直接返回,不像 refresh 那样写入新的错误。
pub fn open(&mut self, id: &str) {
let Some(provider) = &mut self.provider else {
return;
};
// 成功替换 opened 并清错;失败只更新 error,用户仍可查看之前成功打开的内容。
match provider.open_file(id) {
Ok(source) => {
self.opened = Some(source);
+16 -1
View File
@@ -9,8 +9,9 @@ use std::hash::{Hash, Hasher};
/// 由语义 key 生成的稳定控件身份,跨帧移动和文字变化不应改变它。
pub struct WidgetId(u64);
impl WidgetId {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
/// 对调用方语义 key 求哈希,生成不随控件位置或标签变化的跨帧 WidgetId;相同 key 会得到同一身份。
pub fn new(key: impl Hash) -> Self {
// 语义 key 哈希为会话内身份;不依赖布局坐标,但调用方仍需避免重复 key,哈希并非全局唯一保证。
let mut hash = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut hash);
Self(hash.finish())
@@ -37,6 +38,7 @@ pub enum Role {
impl Role {
/// 判断控件角色是否可以接受键盘焦点。
fn focusable(self) -> bool {
// 行用于指针选择,分隔条/滚动条用于拖动,这三类不加入键盘 Tab 焦点循环。
!matches!(self, Self::Splitter(_) | Self::Scrollbar(_) | Self::Row)
}
}
@@ -77,15 +79,19 @@ pub struct VisualState {
pub struct Interaction {
pointer: Option<Point>,
hovered: Option<WidgetId>,
// 按下时捕获的控件;即使指针移动到别处,也保留原身份直到释放或取消。
pointer_active: Option<WidgetId>,
// 键盘按下时的焦点身份;释放时再次匹配,避免焦点已变却激活新控件。
key_active: Option<WidgetId>,
focused: Option<WidgetId>,
// 区分键盘导航和鼠标获得焦点,visual 仅为键盘来源显示焦点外观。
keyboard_focus: bool,
}
impl Interaction {
/// 按逆绘制顺序选择指针处的管理控件,避免重叠控件同时激活。
pub fn hit<'a, A>(&self, targets: &'a [Target<A>]) -> Option<&'a Target<A>> {
let p = self.pointer?;
// 几何命中包含禁用控件,因此上层禁用项会挡住下层项;是否允许操作由 press/cursor 分别判断。
targets.iter().rev().find(|t| t.rect.contains(p))
}
/// 更新指针与悬停目标,但不在移动时直接触发点击。
@@ -95,11 +101,13 @@ impl Interaction {
}
/// 清除离开窗口后的指针/悬停状态。
pub fn pointer_left(&mut self) {
// 离开窗口只清位置和悬停,保留捕获身份供外层拖动流程使用;完整失焦由 blur 处理。
self.pointer = None;
self.hovered = None;
}
/// 捕获启用控件的稳定身份,并按角色更新键盘焦点。
pub fn press<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<Target<A>> {
// 鼠标按下撤销旧键盘激活并关闭键盘焦点外观;点空白或不可聚焦角色会清除 focused。
self.key_active = None;
self.keyboard_focus = false;
let target = self.hit(targets).filter(|t| t.enabled).cloned();
@@ -109,6 +117,7 @@ impl Interaction {
}
/// 只有释放时仍处于原启用目标内才返回 action,拖出后释放不触发。
pub fn release<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<A> {
// take 先消费捕获,保证同一次按下最多激活一次;TextField 不走按钮 release action。
let active = self.pointer_active.take()?;
self.hit(targets)
.filter(|t| t.enabled && t.id == active && t.role != Role::TextField)
@@ -134,6 +143,7 @@ impl Interaction {
self.focused,
self.key_active,
);
// 重建 UI 后必须仍存在且启用才保留焦点/捕获;hover 则依据当前指针重新命中,包含禁用项。
let exists = |id| targets.iter().any(|t| t.id == id && t.enabled);
self.focused = self.focused.filter(|id| exists(*id));
self.pointer_active = self.pointer_active.filter(|id| exists(*id));
@@ -155,6 +165,7 @@ impl Interaction {
}
/// 沿启用且可聚焦的目标循环导航,支持正向与反向遍历。
pub fn focus_next<A: Clone>(&mut self, targets: &[Target<A>], back: bool) -> Option<Target<A>> {
// 键盘导航先取消未完成按压,避免切换焦点后补发旧激活;候选顺序沿本帧目标顺序。
self.cancel();
self.keyboard_focus = true;
let candidates = targets
@@ -166,6 +177,7 @@ impl Interaction {
return None;
}
let current = candidates.iter().position(|t| Some(t.id) == self.focused);
// 有当前焦点时环绕前进/后退;无有效焦点时正向选首项、反向选末项。
let index = match current {
Some(i) if back => (i + candidates.len() - 1) % candidates.len(),
Some(i) => (i + 1) % candidates.len(),
@@ -185,6 +197,7 @@ impl Interaction {
}
/// 结束键盘激活并按有效控件身份返回操作。
pub fn key_release<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<A> {
// 只有释放时仍聚焦相同启用目标才返回动作;take 防止重复 key-up 重复激活。
let active = self.key_active.take()?;
self.focused(targets)
.filter(|t| t.id == active)
@@ -192,6 +205,7 @@ impl Interaction {
}
/// 组合悬停、按下、焦点、选中和启用状态,供控件选择正确样式。
pub fn visual(&self, id: WidgetId, selected: bool, enabled: bool) -> VisualState {
// 禁用控件不显示交互状态;指针按压需仍悬停才显示 pressed,拖出后保留捕获但取消按下外观。
let hovered = enabled && self.hovered == Some(id);
VisualState {
hovered,
@@ -208,6 +222,7 @@ impl Interaction {
}
/// 按命中角色选择系统光标,例如文本、分隔条或可点击按钮。
pub fn cursor<A>(&self, targets: &[Target<A>]) -> Cursor {
// 拖动分隔条/滚动条时优先沿用捕获目标的光标,避免指针跨过其他控件导致拖动光标跳变。
let target = self
.pointer_active
.and_then(|id| {
+5 -2
View File
@@ -1,9 +1,12 @@
//! 宿主管理控件的共享组件入口。主题、绘制原语和跨帧交互状态分离,用户 Endor 页面不依赖这里的控件树。
//!
//! Tessera's host management widgets. No Endor AST, VM, UiNode or ui_bridge dependency.
//! Shares only low-level drawing, font shaping and geometry with guest content.
//! 此模块不依赖 Endor AST、VM、UiNode 或 ui_bridge;只复用底层绘图、字体塑形和几何。
// 跨帧保存 hover/press/focus,管理命中目标、键盘导航与控件视觉状态。
mod interaction;
// 提供管理界面统一的颜色、尺寸和排版常量,避免各工具各自定义主题。
pub mod theme;
// 把控件状态转换为显示列表和交互目标;本模块不承担用户源程序的执行。
mod widgets;
// 向上层隐藏内部文件布局,统一导出交互状态与控件类型。
pub use interaction::{Axis, Cursor, Interaction, Role, Target, VisualState, WidgetId};
pub use widgets::{Button, Ui};
+10 -4
View File
@@ -1,6 +1,7 @@
//! 宿主控件的颜色与状态调色。根据背景、强调色和交互状态生成可读颜色,不改变业务状态。
//!
use crate::display_list::Color;
// 宿主管理界面的白色基础背景;这些常量不会修改业务页面自己的颜色。
pub const BG: Color = Color::rgb(255, 255, 255);
// A visibly separate chrome layer against the warm, near-white content canvas.
pub const BAR: Color = Color::rgb(230, 233, 237);
@@ -9,6 +10,7 @@ pub const TEXT: Color = Color::rgb(35, 42, 53);
pub const MUTED: Color = Color::rgb(103, 114, 130);
pub const ERROR: Color = Color::rgb(175, 42, 49);
pub const OK: Color = Color::rgb(31, 121, 80);
// 供设置页选择的六个强调色候选;Theme::new 还会按可读性规则调整传入颜色。
pub const PALETTE: [Color; 6] = [
Color::rgb(22, 113, 213),
Color::rgb(0, 132, 143),
@@ -23,27 +25,29 @@ pub struct Theme {
pub accent: Color,
}
impl Theme {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
/// 保存经过亮度限制的强调色,所有控件状态色都从该色与管理界面背景混合得到。
pub fn new(accent: Color) -> Self {
// 构造时统一压低过亮强调色,之后 hover/pressed 等样式都从调整后的 accent 派生。
Self {
accent: readable_accent(accent),
}
}
/// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。
/// 以 10% 强调色混合背景,得到未悬停的柔和选中底色。
pub fn soft(self) -> Color {
mix(BG, self.accent, 0.10)
}
/// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。
/// 以 17% 强调色混合背景,得到比 soft 更明显的悬停底色。
pub fn hover(self) -> Color {
mix(BG, self.accent, 0.17)
}
/// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。
/// 以 29% 强调色混合背景,增强按下状态;混色输出始终不透明。
pub fn pressed(self) -> Color {
mix(BG, self.accent, 0.29)
}
}
/// 按比例混合颜色通道,作为状态颜色的基础运算。
pub fn mix(a: Color, b: Color, amount: f32) -> Color {
// 逐 RGB 通道线性插值并四舍五入,amount 钳到 [0,1];结果使用 rgb,输出 alpha 固定为 255。
let channel =
|a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * amount.clamp(0.0, 1.0)).round() as u8;
Color::rgb(
@@ -54,6 +58,8 @@ pub fn mix(a: Color, b: Color, amount: f32) -> Color {
}
/// 根据背景选择可读的强调色,供选中与焦点状态使用。
pub fn readable_accent(mut c: Color) -> Color {
// 用 299/587/114 加权亮度做简单阈值判断,过亮时每轮各通道乘 0.94 变暗。
// 这是启发式规则,不是对任意背景执行通用对比度求解。
while (c.red as u32 * 299 + c.green as u32 * 587 + c.blue as u32 * 114) / 1000 > 155 {
c = Color::rgb(
(c.red as f32 * 0.94) as u8,
+30 -16
View File
@@ -20,7 +20,7 @@ pub struct Button<'a> {
pub role: Role,
}
impl<'a> Button<'a> {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
/// 创建默认启用、未选中的普通按钮描述,借用 label 而不分配文本。
pub fn new(label: &'a str) -> Self {
Self {
label,
@@ -29,17 +29,17 @@ impl<'a> Button<'a> {
role: Role::Button,
}
}
/// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。
/// 按 builder 风格设置选中状态,返回描述供链式调用。
pub fn selected(mut self, value: bool) -> Self {
self.selected = value;
self
}
/// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。
/// 设置启用状态,影响外观和目标的可激活资格。
pub fn enabled(mut self, value: bool) -> Self {
self.enabled = value;
self
}
/// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。
/// 设置控件语义角色,影响焦点资格、光标及 Tab 底线等外观。
pub fn role(mut self, value: Role) -> Self {
self.role = value;
self
@@ -51,10 +51,11 @@ pub struct Ui<A> {
pub list: DisplayList,
pub targets: Vec<Target<A>>,
pub theme: Theme,
// 绘制使用跨帧交互状态的本轮副本,只查询 visual/captured;事件处理器在外部更新权威状态。
interaction: Interaction,
}
impl<A: Hash> Ui<A> {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
/// 建立空显示列表和目标集合,保存本轮主题与交互状态快照。
pub fn new(theme: Theme, interaction: Interaction) -> Self {
Self {
list: DisplayList::default(),
@@ -63,7 +64,7 @@ impl<A: Hash> Ui<A> {
interaction,
}
}
/// 使用当前主题绘制控件填充、轮廓或文字原语。
/// 追加无圆角填充矩形命令;不注册交互目标。
pub fn fill(&mut self, r: Rect, color: Color) {
self.list.push(DrawCommand::FillRoundedRect {
rect: r,
@@ -71,9 +72,10 @@ impl<A: Hash> Ui<A> {
color,
});
}
/// 使用当前主题绘制控件填充、轮廓或文字原语。
/// 追加向内收缩的圆角描边,使传入矩形作为控件外边界。
fn outline(&mut self, r: Rect, color: Color, width: f32) {
self.list.push(DrawCommand::StrokeRoundedRect {
// 描边以路径中心为基准,向内缩半个线宽,使描边整体尽量位于传入矩形内;尺寸钳非负。
rect: Rect::new(
r.x + width / 2.0,
r.y + width / 2.0,
@@ -85,7 +87,7 @@ impl<A: Hash> Ui<A> {
color,
});
}
/// 使用当前主题绘制控件填充、轮廓或文字原语。
/// 追加等宽或界面字体文字命令;调用方负责裁剪与可用宽度。
pub fn label(&mut self, x: f32, y: f32, value: &str, color: Color, code: bool) {
self.list.push(DrawCommand::Text {
origin: Point::new(x, y),
@@ -112,6 +114,7 @@ impl<A: Hash> Ui<A> {
selected: bool,
enabled: bool,
) -> VisualState {
// 先取得上帧输入对应的视觉状态,再登记本帧目标;绘制本身不触发 action。
let response = self.interaction.visual(id, selected, enabled);
self.targets.push(Target {
id,
@@ -122,8 +125,9 @@ impl<A: Hash> Ui<A> {
});
response
}
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
/// 按视觉状态绘制控件背景和焦点轮廓;本方法不注册目标。
fn surface(&mut self, rect: Rect, s: VisualState, base: Option<Color>) {
// 背景优先级为禁用、按下、悬停、选中、默认;焦点轮廓单独绘制,可与选中同时存在。
let color = if !s.enabled {
base
} else if s.pressed {
@@ -142,8 +146,9 @@ impl<A: Hash> Ui<A> {
self.outline(rect, self.theme.accent, 1.5);
}
}
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
/// 登记按钮目标,绘制状态背景、裁剪后的标签及可选 Tab 底线,返回组合视觉状态。
pub fn button(&mut self, rect: Rect, action: A, button: Button<'_>) -> VisualState {
// 默认以 action 哈希为稳定身份;同一帧多个独立控件若 action 完全相同会共享身份,调用方需合理区分。
let id = WidgetId::new(&action);
let s = self.register(
id,
@@ -155,6 +160,7 @@ impl<A: Hash> Ui<A> {
);
self.surface(rect, s, None);
self.list.push(DrawCommand::PushClip(rect));
// 扣掉左右各 8 的内边距后按真实字体截断,并用 PushClip 保证文字和标签底线不越出按钮。
let label = text::fit(
button.label,
(rect.width - 16.0).max(0.0),
@@ -175,6 +181,7 @@ impl<A: Hash> Ui<A> {
},
false,
);
// 仅 Tab 角色在选中时显示底部强调线,普通按钮选中只走 surface 背景。
if s.selected && button.role == Role::Tab {
self.fill(
Rect::new(rect.x, rect.y + rect.height - 2.0, rect.width, 2.0),
@@ -184,7 +191,7 @@ impl<A: Hash> Ui<A> {
self.list.push(DrawCommand::PopClip);
s
}
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
/// 登记文本输入角色并绘制背景和焦点/悬停描边,不负责实际文本与光标。
pub fn text_field(&mut self, rect: Rect, action: A, focused: bool) {
let s = self.register(
WidgetId::new(&action),
@@ -194,6 +201,7 @@ impl<A: Hash> Ui<A> {
false,
true,
);
// 文本框固定白底,外部 focused 或键盘焦点都使用强调色描边,悬停次之。
self.surface(rect, s, Some(theme::BG));
self.outline(
rect,
@@ -208,7 +216,8 @@ impl<A: Hash> Ui<A> {
);
}
/// A terminal prompt shares its surface with output; its caret is the focus cue.
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
/// 只登记无边框终端输入的交互区域,外观由调用方绘制。
// 终端输入与输出共享背景,只注册文本输入命中区,不绘制框线或文字,光标由 Console 负责。
pub fn inline_input(&mut self, rect: Rect, action: A) {
self.register(
WidgetId::new(&action),
@@ -219,7 +228,8 @@ impl<A: Hash> Ui<A> {
true,
);
}
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
/// 分别接受行背景区域和可见命中区域,登记行选择并绘制其状态背景。
// 行背景可绘制完整行,但 hit 可裁到可见部分;不要把裁剪区外的行注册成可点击区域。
pub fn row(&mut self, paint: Rect, hit: Rect, action: A, selected: bool) {
let s = self.register(
WidgetId::new(&action),
@@ -231,7 +241,7 @@ impl<A: Hash> Ui<A> {
);
self.surface(paint, s, None);
}
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
/// 登记色块目标,绘制按压变暗、外圈及选中勾号。
pub fn swatch(&mut self, rect: Rect, action: A, color: Color, selected: bool) {
let s = self.register(
WidgetId::new(&action),
@@ -241,6 +251,7 @@ impl<A: Hash> Ui<A> {
selected,
true,
);
// 滚动条手柄捕获时保持强调色,悬停使用混色,其余用中性底色;实际偏移换算由调用方处理。
self.fill(
rect,
if s.pressed {
@@ -249,6 +260,7 @@ impl<A: Hash> Ui<A> {
color
},
);
// 色块在交互或选中时增加外圈;按下变暗且加粗,选中再绘制勾号。
if s.hovered || s.focused || selected {
self.outline(
Rect::new(
@@ -269,8 +281,9 @@ impl<A: Hash> Ui<A> {
self.label(rect.x + 10.0, rect.y + 6.0, "✓", theme::BG, false);
}
}
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
/// 登记扩大的拖动命中区,绘制捕获/悬停强调线。
pub fn splitter(&mut self, rect: Rect, id: WidgetId, axis: Axis) {
// 分隔线命中区四边扩展 3,提高细线可拖动性;真实绘制线宽与命中区域独立。
let hit = Rect::new(
rect.x - 3.0,
rect.y - 3.0,
@@ -278,6 +291,7 @@ impl<A: Hash> Ui<A> {
rect.height + 6.0,
);
let mut s = self.register(id, hit, Role::Splitter(axis), None, false, true);
// 分隔条捕获后即使拖出命中区仍显示按压态,不采用普通按钮“拖出取消外观”的规则。
s.pressed |= self.interaction.captured(id);
let color = if s.pressed || s.hovered {
self.theme.accent
@@ -294,7 +308,7 @@ impl<A: Hash> Ui<A> {
};
self.fill(line, color);
}
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
/// 登记滚动手柄目标并绘制其状态颜色,偏移和拖动倍率由外部维护。
pub fn scrollbar(&mut self, rect: Rect, id: WidgetId, axis: Axis) {
let mut s = self.register(id, rect, Role::Scrollbar(axis), None, false, true);
s.pressed |= self.interaction.captured(id);
+4
View File
@@ -7,9 +7,13 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// 托管页面注册表的不可变投影,原生工具只使用稳定 ID 与显示元数据。
pub struct Tab {
// 会话内稳定 ID,供原生侧发起切页/关闭操作;不能直接当作 VM 对象地址。
pub id: u64,
// 本次发布的标题文本副本,原生界面显示时无需读取 VM。
pub title: String,
// 该标签是否是此窗口当前活动页;页面注册表顺序与选中状态分别表示。
pub active: bool,
// 开发工具页的类别标识;普通页省略此字段,反序列化缺失字段时默认为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub developer_tool: Option<String>,
}