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

219 lines
8.6 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 显示行与命中布局,并按需滚动到光标。
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,
);
}