219 lines
8.6 KiB
Rust
219 lines
8.6 KiB
Rust
//! 绘制 Console 输入、结果历史和对应命中区域。
|
||
//! 这里消费工具状态并生成显示列表;用户程序的编译执行由提交路径和 worker 完成。
|
||
//!
|
||
use super::*;
|
||
|
||
/// 将有界 HTTP 活动、当前编辑输入和输入法预编辑投影为 Console 显示行与命中布局,并按需滚动到光标。
|
||
pub(super) fn build(
|
||
p: &mut Painter,
|
||
state: &mut DeveloperToolsState,
|
||
records: &[Exchange],
|
||
viewport: Rect,
|
||
) {
|
||
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);
|
||
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),
|
||
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")
|
||
}) {
|
||
let kind = if r.path.ends_with("/repl") {
|
||
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 状态行,外部请求保留来源与诊断入口。
|
||
let local = r.origin == "Console" && r.path.ends_with("/repl") && r.method == "POST";
|
||
if !local {
|
||
let mut title = Line::new(
|
||
format!("#{} {} {} {}", r.id, r.timestamp, r.origin, kind),
|
||
MUTED,
|
||
);
|
||
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,
|
||
));
|
||
}
|
||
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,
|
||
));
|
||
}
|
||
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);
|
||
}
|
||
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();
|
||
for line in &lines {
|
||
line.value.hash(&mut hash);
|
||
}
|
||
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);
|
||
}
|
||
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];
|
||
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(
|
||
(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;
|
||
}
|
||
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,
|
||
});
|
||
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。
|
||
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,
|
||
);
|
||
}
|