1083 lines
42 KiB
Rust
1083 lines
42 KiB
Rust
//! Console/Sources/Network/Settings 的宿主交互协调与绘制状态。
|
|
//! 工具标签有独立焦点、滚动和文本选择状态;源程序与结果来自当前发布文档,提交动作通过本地 API 进入 worker。
|
|
//! 输入法预编辑、光标、选择和控件捕获分别处理,避免把界面操作直接当成业务模型写入。
|
|
//!
|
|
//! Native developer workspace. Coordinates and scroll offsets are logical pixels.
|
|
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;
|
|
|
|
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)]
|
|
/// 管理面板中可区分的输入/列表/详情区域。
|
|
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),
|
|
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 {
|
|
states: [DeveloperToolsState; 4],
|
|
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 {
|
|
tab,
|
|
proxy: Some(proxy.clone()),
|
|
..Default::default()
|
|
}
|
|
});
|
|
Self { states, active: 0 }
|
|
}
|
|
/// 选择一个工具页并恢复它各自保留的输入、焦点和滚动状态。
|
|
pub fn select(&mut self, name: Option<&str>) {
|
|
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;
|
|
/// 将包装类型的只读访问转交给内部当前上下文。
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.states[self.active]
|
|
}
|
|
}
|
|
impl std::ops::DerefMut for Workspace {
|
|
/// 将包装类型的可变访问转交给内部当前上下文。
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.states[self.active]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
/// 一份工具页面的输入、选择、滚动、焦点和显示配置。
|
|
pub struct DeveloperToolsState {
|
|
pub open: bool,
|
|
pointer: Point,
|
|
source_selection: SourceSelection,
|
|
pub interaction: Interaction,
|
|
pub repaint: bool,
|
|
pub proxy: Option<winit::event_loop::EventLoopProxy<AppEvent>>,
|
|
pub cloud_sources: SourceWorkspace,
|
|
pub tab: Tab,
|
|
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,
|
|
pub custom: String,
|
|
pub accent: Color,
|
|
pub input_error: String,
|
|
tree_width: f32,
|
|
network_fraction: f32,
|
|
scroll: [Scroll; 5],
|
|
// 各 Region 独立保存行文本指纹与最大宽度,颜色/选择改变无需重测,文本改变才失效。
|
|
widths: [Option<(u64, f32)>; 5],
|
|
reveal_input: bool,
|
|
console_stamp: u64,
|
|
drag: Option<Drag>,
|
|
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 {
|
|
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
|
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 {
|
|
pub panel: Rect,
|
|
pub widgets: Vec<Target<Action>>,
|
|
splits: Vec<(Rect, Split)>,
|
|
regions: Vec<(Rect, Region, f32, f32)>,
|
|
thumbs: Vec<(Rect, Region, bool, f32)>,
|
|
input: Option<ConsoleInputLayout>,
|
|
source: Option<(Rect, Point, Vec<String>)>,
|
|
}
|
|
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 {
|
|
target.rect.x += dx;
|
|
target.rect.y += dy;
|
|
}
|
|
for (rect, _) in &mut self.splits {
|
|
rect.x += dx;
|
|
rect.y += dy;
|
|
}
|
|
for (rect, _, _, _) in &mut self.regions {
|
|
rect.x += dx;
|
|
rect.y += dy;
|
|
}
|
|
for (rect, _, _, _) in &mut self.thumbs {
|
|
rect.x += dx;
|
|
rect.y += dy;
|
|
}
|
|
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 与 Console 历史的一致性。
|
|
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());
|
|
}
|
|
}
|
|
/// 根据当前工具命中区域选择文本、拖动或默认系统光标。
|
|
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))
|
|
{
|
|
return CursorIcon::Text;
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
/// 清理指针或焦点离开后的悬停/捕获状态,避免按下状态残留。
|
|
pub fn pointer_left(&mut self) {
|
|
self.interaction.pointer_left();
|
|
}
|
|
/// 清理指针或焦点离开后的悬停/捕获状态,避免按下状态残留。
|
|
pub fn blur(&mut self) {
|
|
self.interaction.blur();
|
|
self.drag = None;
|
|
self.focus = None;
|
|
self.preedit.clear();
|
|
}
|
|
/// 记录按下时的控件或文本选择目标,为释放激活和拖动保留捕获。
|
|
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);
|
|
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 {
|
|
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<Action>,
|
|
layout: &DeveloperToolsLayout,
|
|
p: Point,
|
|
activity: &Activity,
|
|
) {
|
|
self.focus = None;
|
|
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);
|
|
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();
|
|
for (i, part) in line.grapheme_indices(true) {
|
|
let advance =
|
|
text::measure(&line[..i + part.len()], TextStyle::code()).width;
|
|
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();
|
|
}
|
|
Some(Action::ClearNetwork) => {
|
|
self.network_after = activity.lock().unwrap().records.back().map_or(0, |r| r.id);
|
|
self.selected_request = None;
|
|
}
|
|
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(),
|
|
Some(Action::CloudOpen(id)) => {
|
|
self.cloud_sources.open(&id);
|
|
self.scroll[Region::Source as usize] = Scroll::default();
|
|
}
|
|
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 {
|
|
self.tab = Tab::Network;
|
|
self.network_after = 0;
|
|
self.filter.clear();
|
|
}
|
|
self.scroll[Region::Details as usize] = Scroll::default();
|
|
}
|
|
Some(Action::Theme(i)) => {
|
|
self.accent = PALETTE[i];
|
|
self.custom = format!(
|
|
"#{:02X}{:02X}{:02X}",
|
|
self.accent.red, self.accent.green, self.accent.blue
|
|
);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
/// 结束捕获并按同一目标/启用条件决定是否执行操作。
|
|
pub fn release(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) {
|
|
self.pointer = p;
|
|
self.interaction.pointer_moved(p, &layout.widgets);
|
|
let action = self.interaction.release(&layout.widgets);
|
|
self.drag = None;
|
|
self.source_selection.dragging = false;
|
|
if action.is_some() {
|
|
self.activate(action, layout, p, activity);
|
|
}
|
|
}
|
|
/// 按工具控件焦点处理 Tab、确认等管理键盘交互。
|
|
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;
|
|
}
|
|
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 {
|
|
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) {
|
|
self.cursor = self.input.len();
|
|
self.reveal_cursor();
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
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
|
|
}
|
|
/// 更新工具内悬停、拖动、选择或分隔条位置。
|
|
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 {
|
|
Some(Drag::Split(Split::Tree)) => {
|
|
self.tree_width =
|
|
(p.x - layout.panel.x).clamp(120.0, (layout.panel.width - 180.0).max(120.0))
|
|
}
|
|
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 {
|
|
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 {
|
|
self.follow = false;
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
/// 将滚轮作用到当前工具的可滚动区域,钳制本工具偏移。
|
|
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))
|
|
{
|
|
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 {
|
|
self.follow = false;
|
|
}
|
|
}
|
|
}
|
|
/// 在当前选择/光标处插入输入文本,并更新光标与可见范围。
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
/// 返回输入法候选窗应跟随的逻辑光标位置。
|
|
pub fn ime_position(&self, layout: &DeveloperToolsLayout) -> Point {
|
|
if let Some(input) = layout.input {
|
|
let before = &self.input[..self.cursor];
|
|
let row = before.matches('\n').count() as f32;
|
|
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),
|
|
);
|
|
}
|
|
Point::new(layout.panel.x + 20.0, 60.0)
|
|
}
|
|
/// 取得当前工具文本选择内容,供复制命令使用。
|
|
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 光标/选择边界。
|
|
pub fn key(&mut self, key: &winit::keyboard::NamedKey, shift: bool, command: bool) {
|
|
use winit::keyboard::NamedKey::*;
|
|
if *key == Escape {
|
|
self.focus = None;
|
|
self.preedit.clear();
|
|
self.interaction.blur();
|
|
return;
|
|
}
|
|
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 {
|
|
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;
|
|
}
|
|
if self.focus != Some(Focus::Input) {
|
|
if *key == Backspace {
|
|
match self.focus {
|
|
Some(Focus::Filter) => {
|
|
if self.selection {
|
|
self.filter.clear();
|
|
} else {
|
|
self.filter.pop();
|
|
}
|
|
}
|
|
Some(Focus::Custom) => {
|
|
if self.selection {
|
|
self.custom.clear();
|
|
} else {
|
|
self.custom.pop();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
self.selection = false;
|
|
}
|
|
return;
|
|
}
|
|
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 {
|
|
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, "");
|
|
}
|
|
}
|
|
ArrowLeft => {
|
|
self.cursor = if command {
|
|
0
|
|
} else {
|
|
self.input[..self.cursor]
|
|
.grapheme_indices(true)
|
|
.next_back()
|
|
.map_or(0, |(i, _)| i)
|
|
};
|
|
self.selection = false;
|
|
}
|
|
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 => {
|
|
self.cursor = if command {
|
|
0
|
|
} else {
|
|
self.input[..self.cursor].rfind('\n').map_or(0, |i| i + 1)
|
|
};
|
|
self.selection = false;
|
|
}
|
|
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())
|
|
};
|
|
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();
|
|
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::<usize>();
|
|
self.selection = false;
|
|
}
|
|
Escape => {
|
|
self.focus = None;
|
|
self.preedit.clear();
|
|
}
|
|
_ => {}
|
|
}
|
|
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(
|
|
&"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
|
|
)?;
|
|
let mut response = String::new();
|
|
stream.read_to_string(&mut response)?;
|
|
Ok(())
|
|
};
|
|
if let Err(error) = send()
|
|
&& let Some(proxy) = proxy
|
|
{
|
|
let _ = proxy.send_event(AppEvent::ConsoleFailed {
|
|
message: error.to_string(),
|
|
source: String::new(),
|
|
});
|
|
}
|
|
});
|
|
}
|
|
/// 把当前工具输入按所选模式提交给本地 API,并异步反馈结果。
|
|
fn submit(&mut self) {
|
|
self.submit_console(false);
|
|
}
|
|
/// 提交 Console 输入到持久运行时,而不是把全部历史重新执行一遍。
|
|
fn submit_console(&mut self, reset: bool) {
|
|
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 展示。
|
|
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))?;
|
|
stream.set_read_timeout(Some(Duration::from_secs(120)))?;stream.set_write_timeout(Some(Duration::from_secs(10)))?;
|
|
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)?;
|
|
let mut response=String::new();stream.read_to_string(&mut response)?;Ok(())
|
|
};
|
|
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());
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 按当前工具生成设备比例显示列表与逻辑坐标命中布局,绘制后校正跨帧焦点和捕获。
|
|
pub fn build(
|
|
width: u32,
|
|
height: u32,
|
|
scale: f32,
|
|
doc: &RenderDocument,
|
|
state: &mut DeveloperToolsState,
|
|
activity: &Activity,
|
|
) -> (DisplayList, DeveloperToolsLayout) {
|
|
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;
|
|
let x = 0.0;
|
|
let panel = Rect::new(x, 0.0, w, height);
|
|
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::<Vec<_>>();
|
|
let bottom = height - 22.0;
|
|
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.
|
|
// 分隔条最后绘制/注册,命中优先级与可见叠放相同,细线扩展命中区不会被后画的背景遮住。
|
|
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();
|
|
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)
|
|
}
|