Files
endorphin-tessera-gui/src/shell/developer_tools/console.rs
T

248 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 绘制 Console 输入、结果历史和对应命中区域。
//! 这里消费工具状态并生成显示列表;用户程序的编译执行由提交路径和 worker 完成。
//!
use super::*;
/// 将有界 HTTP 活动、当前编辑输入和输入法预编辑投影为 Console 显示行与命中布局,并按需滚动到光标。
// developer_tools::build 在 Console 标签选中时调用;records 是父入口克隆的活动快照,后续绘制不持活动日志锁。
pub(super) fn build(
p: &mut Painter,
state: &mut DeveloperToolsState,
records: &[Exchange],
viewport: Rect,
) {
// x 是工具画布横向原点,w 为可用宽度;本页标题及行区纵坐标沿用父工具布局约定。
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);
// Clear 的可用性与下面输出记录过滤条件一致,仅存在清屏水位之后的可展示记录才启用。
p.enabled_button(
Rect::new(x + 3.0, 32.0, 62.0, 29.0),
"Clear",
Action::ClearConsole,
records.iter().any(|r| {
r.id > state.console_after
&& matches!(r.method.as_str(), "PUT" | "POST" | "DELETE")
&& !r.path.ends_with("/events")
}),
);
p.button(
Rect::new(x + 68.0, 32.0, 106.0, 29.0),
// 按钮文本反映当前跟随设置,点击 Action::Follow 后由状态处理器切换,不在绘制时直接改 follow。
if state.follow {
"Follow: on"
} else {
"Follow: off"
},
Action::Follow,
state.follow,
);
p.button(
Rect::new(x + 181.0, 32.0, 119.0, 29.0),
"Reset session",
Action::ResetSession,
false,
);
// 窄窗口省略辅助会话说明,保留清屏、跟随和重置按钮,不改变真实会话状态。
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")
&& !r.path.ends_with("/events")
}) {
// 按路径及方法给观察记录选择显示标签,分类不执行 API,也不决定 worker 的真实请求处理。
let kind = if r.path.ends_with("/repl") {
// 同一 /repl 的 DELETE 显示会话重置,其他方法显示持续会话执行,区别于独立 /evaluate。
if r.method == "DELETE" {
"RESET SESSION"
} else {
"REPL · live objects"
}
} else if r.path.ends_with("/evaluate") {
"ISOLATED API EVALUATION"
} else if r.method == "PUT" {
"FULL SOURCE · live program"
} else {
"CLEAR DOCUMENT"
};
// Console 自己发起的 REPL 请求省略冗余标题和 HTTP 状态行,外部请求保留来源与诊断入口。
// 只简化本工具发出的 POST /repl;重置和外部请求仍保留来源与状态,避免混淆不同操作。
let local = r.origin == "Console" && r.path.ends_with("/repl") && r.method == "POST";
// 非本地简化输出加可点击标题,Action::Request 根据稳定请求 ID 打开 Network 详情。
if !local {
let mut title = Line::new(
format!("#{} {} {} {}", r.id, r.timestamp, r.origin, kind),
MUTED,
);
// 强调分隔线标记一次执行起点,标题动作与文本一起交给 Painter 注册命中目标。
title.divider = Some(Divider::Accent);
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 { " " }),
TEXT,
));
}
// 颜色只按已记录 HTTP 状态判定,pending 尚无失败码时暂用成功色变量,但实际分支显示灰色 Executing。
let color = if r.status.is_some_and(|s| s >= 400) {
ERROR
} else {
OK
};
// 未完成请求显示 Executing,完成后才解析响应;HTTP 4xx/5xx 使用错误颜色。
if let Some(status) = r.status {
if !local {
lines.push(Line::new(
format!("‹ {status} · {} ms", r.elapsed_ms),
color,
));
}
// 响应格式化由 painter 模块解析已记录正文;预览截断导致 JSON 无法解析时会回退原文显示。
console_result(&mut lines, &r.response, color);
} else {
lines.push(Line::new(" Executing…", MUTED));
}
// 每条请求结束添加中性分隔线,包括仍执行中的记录,让后续输入与相邻历史有明确边界。
let mut separator = Line::new("", MUTED);
separator.divider = Some(Divider::Neutral);
lines.push(separator);
}
// 没有可展示历史时补欢迎说明,这些行也属于 transcript 输出,后面的输入起始偏移包含它们。
if lines.is_empty() {
multiline(
&mut lines,
"Endor · live session\nCreate objects here; call page.render(...) to display.\n",
MUTED,
);
}
use std::hash::{Hash, Hasher};
// 只对输出行文本求 stamp,用来判断 Follow 是否需要跳到底部;当前编辑输入不参与输出变化检测。
let mut hash = std::collections::hash_map::DefaultHasher::new();
// 只散列 value,不把选择、颜色或分隔线样式变化当成新输出,避免无关重绘触发跟随到底部。
for line in &lines {
line.value.hash(&mut hash);
}
let stamp = hash.finish();
// 保存本次指纹作下一帧比较,Follow 开关只决定是否采用变化信号,不影响指纹更新。
let output_changed = stamp != state.console_stamp;
state.console_stamp = stamp;
// 记录输入开始的行号,后续光标和点击区域都需加上 transcript 输出行偏移。
let first_input = lines.len();
// 输出历史与可编辑输入共用一个 Transcript 区域,底部留出状态提示和滚动条空间。
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();
// 只在输入框持焦点时把 preedit 插到显示副本,编辑字符串本身仍等最终 IME Commit。
if state.focus == Some(Focus::Input) {
input.insert_str(state.cursor, &state.preedit);
}
// 显示输入保留换行并加提示符;全选高亮只跟随 Input 焦点,不把历史输出当成可替换输入。
for (i, line) in input.split('\n').enumerate() {
let mut line = Line::new(format!("{}{line}", if i == 0 { "> " } else { " " }), TEXT);
line.selected = state.selection && state.focus == Some(Focus::Input);
lines.push(line);
}
// 提示符用真实等宽字体度量,后续光标和鼠标定位共享同一文本起点。
let prefix = text::measure("> ", TextStyle::code()).width;
// cursor 是输入字符串的字节边界,由编辑逻辑维护;通过换行计数定位行,再用真实字体宽度计算列位置。
let before = &state.input[..state.cursor];
// cursor 以原输入的 UTF-8 字节位置计,按其之前换行数求行号,最后一段就是当前行已有正文。
let row = before.matches('\n').count() as f32;
let column = before.rsplit('\n').next().unwrap_or("");
// 列正文与 preedit 一起测量,光标画在组合输入后;提示符宽度另加而不参与待编辑字符串。
let caret_x =
prefix + text::measure(&format!("{column}{}", state.preedit), TextStyle::code()).width;
// 当前输入行加上输出行数得到整个 transcript 中的纵向位置,滚动量稍后统一扣除。
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 {
// 编辑触发的 reveal 优先保住光标,不跳到历史尾部;校正后清标记,只消费一次请求。
let s = &mut state.scroll[Region::Transcript as usize];
s.y = s.y.clamp(
(caret_y + ROW + 16.0 - area.height).max(0.0).min(caret_y),
caret_y,
);
s.x = s.x.clamp((caret_x + 24.0 - area.width).max(0.0), caret_x);
state.reveal_input = false;
}
// Painter 测量完整内容并钳制最终滚动,必须在此之后再读取 scroll 计算输入命中布局。
p.lines(area, Region::Transcript, &lines, state, follow);
let scroll = state.scroll[Region::Transcript as usize];
// 保存输入正文第一行的实际屏幕原点,包含内容边距、提示符、输出高度及最终两轴滚动。
let origin = Point::new(
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,
});
// 输入可见竖向片段与 transcript 相交,下面只为交集注册 Focus(Input),不覆盖历史文本区域。
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(
area.x,
input_top,
(area.width - 9.0).max(0.0),
input_bottom - input_top,
),
Action::Focus(Focus::Input),
);
}
// 光标单独绘制并限制在文本视口内,不能穿过滚动条或工具页边缘;随后配对 PopClip。
// 光标只是绘制覆盖层,先压入扣除滚动条的 clip,再画一像素竖线并弹出,不扩大输入命中范围。
if state.focus == Some(Focus::Input) {
p.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),
)));
p.fill(
Rect::new(
area.x + 8.0 + caret_x - scroll.x,
origin.y + row * ROW + 1.0,
1.0,
ROW - 2.0,
),
TEXT,
);
p.ui.list.push(DrawCommand::PopClip);
}
// 底部无输入错误时展示快捷键,有错则复用同一位置显示错误文本及错误颜色。
p.label(
x + 10.0,
bottom - 23.0,
if state.input_error.is_empty() {
"Enter: run · Shift+Enter: newline · ⌘/Ctrl+↑↓: history"
} else {
&state.input_error
},
if state.input_error.is_empty() {
MUTED
} else {
ERROR
},
false,
);
}