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

1218 lines
59 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/Sources/Network/Settings 的宿主交互协调与绘制状态。
//! 工具标签有独立焦点、滚动和文本选择状态;源程序与结果来自当前发布文档,提交动作通过本地 API 进入 worker。
//! 输入法预编辑、光标、选择和控件捕获分别处理,避免把界面操作直接当成业务模型写入。
//!
//! Native developer workspace. Coordinates and scroll offsets are logical pixels.
//!
//! shell::app 把命名编辑键、文本/IME 与指针交给当前 Workspace 槽,scene 调用 build 生成下一帧控件和命中布局。
//! activate 只在控件释放确认或文本框按下后消费 Action;build/Painter 只登记 Action,不在绘制时提交 Console 输入。
//! submit_console/initialize_tools 的后台线程只做本地 HTTP 传输;VM 执行结果由 API 活动日志展示,不以线程启动或读完响应判定成功。
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;
// Console、Painter 和 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)]
/// 管理面板中可区分的输入/列表/详情区域。
// 枚举值同时作为 scroll/widths 的数组下标,新增区域时须同步调整两组数组长度。
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),
// 记录区域、是否横轴以及手柄位移转内容位移的倍率;倍率来自 Painter,而不是每次移动重新估算。
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 {
// 数组顺序与 new/select 的工具映射一致,每个槽独立保留输入、日志水位、选区和滚动。
states: [DeveloperToolsState; 4],
active: usize,
}
impl Workspace {
/// 为 Console/Sources/Network/Settings 各创建一份独立状态,共享事件循环代理,初始选择 Console。
// shell/app 创建窗口宿主状态时调用,代理克隆让各工具的异步请求错误能唤醒同一个原生事件循环。
pub fn new(proxy: winit::event_loop::EventLoopProxy<AppEvent>) -> Self {
// 对四种工具各创建一个默认状态,再覆盖 tab/proxy,避免多个工具误共享一个可编辑 input。
let states = [Tab::Console, Tab::Sources, Tab::Network, Tab::Settings].map(|tab| {
DeveloperToolsState {
tab,
proxy: Some(proxy.clone()),
..Default::default()
}
});
Self { states, active: 0 }
}
/// 选择一个工具页并恢复它各自保留的输入、焦点和滚动状态。
// scene::build_scene 根据活动页面的 developer_tool 标记调用;None 关闭工具显示,未知非空名字使用 Console 槽。
pub fn select(&mut self, name: Option<&str>) {
// 名字仅选择已有状态槽,不在这里创建 VM 页面;页面复用/创建通过 initialize_tools 的能力调用完成。
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;
/// 将包装类型的只读访问转交给内部当前上下文。
// scene/app 的字段读取和 build 通过当前槽访问状态,不会在一次读取时遍历其他工具。
fn deref(&self) -> &Self::Target {
&self.states[self.active]
}
}
impl std::ops::DerefMut for Workspace {
/// 将包装类型的可变访问转交给内部当前上下文。
// 原生输入事件调用当前槽方法,切换工具前 blur 旧槽解除交互状态。
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.states[self.active]
}
}
#[derive(Debug)]
/// 一份工具页面的输入、选择、滚动、焦点和显示配置。
pub struct DeveloperToolsState {
// 是否当前展示管理页;关闭时 build 返回空列表/布局,状态本身仍保留供后续重新打开。
pub open: bool,
// 最近工具逻辑坐标,cursor_kind 复用它判断文本区或控件悬停。
pointer: Point,
source_selection: SourceSelection,
// 底层宿主控件焦点、悬停及按下捕获;与文本编辑 focus/drag 配合,不直接改变业务 VM。
pub interaction: Interaction,
// 绘制 reconcile 或源码拖选置位,shell/app 据此安排额外重绘,修正失效控件外观。
pub repaint: bool,
// 后台线程只携带事件代理和自有文本,不借用窗口或 DeveloperToolsState。
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,
// IME 未提交的组合文本,只在 Console 显示副本插入;Commit 后才通过 insert 写正式 input。
pub preedit: String,
pub filter: String,
pub custom: String,
pub accent: Color,
pub input_error: String,
// Sources 左栏宽度及 Network 上下比例分别控制分隔布局,拖动只调整工具空间。
tree_width: f32,
network_fraction: f32,
scroll: [Scroll; 5],
// 各 Region 独立保存行文本指纹与最大宽度,颜色/选择改变无需重测,文本改变才失效。
widths: [Option<(u64, f32)>; 5],
// 下一帧需要显示编辑光标,console::build 测量位置后消费;不是立即修改滚动的命令。
reveal_input: bool,
// Console 输出行指纹,用于新增输出时 Follow;当前未提交 input 不参与这份指纹。
console_stamp: u64,
drag: Option<Drag>,
cloud: bool,
follow: bool,
// 稳定日志 ID,筛选隐藏行仍可保留详情;日志淘汰后详情页回到提示。
selected_request: Option<u64>,
// 清屏水位只隐藏旧记录,与共享 ActivityLog 的实际淘汰分开;Network 有独立水位。
console_after: u64,
network_after: u64,
// 最多 80 条成功启动发送线程的输入,不等于成功执行历史;失败线程可能再由事件恢复输入。
history: Vec<String>,
history_index: Option<usize>,
// 首次进入历史导航前保存当前未提交输入,向下越过最新历史后恢复此草稿。
draft: String,
}
impl Default for DeveloperToolsState {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
// Workspace::new 用它构造四份独立状态;初始不开工具、无焦点,默认启用 Follow 并请求显示输入。
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 {
// 整工具画布的逻辑区域;shell/app 先据此决定输入是否属于管理控件。
pub panel: Rect,
pub widgets: Vec<Target<Action>>,
splits: Vec<(Rect, Split)>,
// 各行面板的区域和完整内容宽高,wheel 查命中,Painter 重绘时根据内容限制最大偏移。
regions: Vec<(Rect, Region, f32, f32)>,
// 滚动手柄矩形及内容/手柄位移倍率,press 找到后保存到 Drag::Scroll。
thumbs: Vec<(Rect, Region, bool, f32)>,
input: Option<ConsoleInputLayout>,
// 源码可见区、已扣滚动/行号的正文原点和完整显示文本,鼠标选择与复制共享本帧数据。
source: Option<(Rect, Point, Vec<String>)>,
}
impl DeveloperToolsLayout {
/// 将工具局部绘制/交互布局平移到实际窗口位置。
// scene::build_scene 把工具移动到侧栏右边时调用;显示列表另以设备比例平移,这里保持逻辑坐标。
pub fn translate(&mut self, dx: f32, dy: f32) {
// 绘制列表已由外层按设备像素平移,这里所有命中矩形和文字原点都按逻辑单位同步移动。
self.panel.x += dx;
self.panel.y += dy;
// 先同步所有通用 Widget 命中,后面再移动额外的分隔条、区域和手柄矩形。
for target in &mut self.widgets {
target.rect.x += dx;
target.rect.y += dy;
}
for (rect, _) in &mut self.splits {
// 分隔条的额外布局与 Widget 目标同时移动,后续 press 才能在平移后的窗口位置找到拖动类别。
rect.x += dx;
rect.y += dy;
}
for (rect, _, _, _) in &mut self.regions {
// 滚轮用区域外框定位内容;只平移外框,不改 Region 身份和完整内容宽高。
rect.x += dx;
rect.y += dy;
}
for (rect, _, _, _) in &mut self.thumbs {
// 手柄几何随窗口平移,已计算的轴方向/内容位移倍率保持不变,不能把平移量当滚动增量。
rect.x += dx;
rect.y += dy;
}
// 输入视口与正文原点必须一起移动,才能让鼠标插入点和 IME 候选窗对齐显示内容。
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 的已选 AST 节点,保留仍有效的选择。
// scene 构建工具画面前调用,仅修正 AST 选择身份;输入与 HTTP 历史来自另外的状态和活动日志。
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());
}
}
/// 根据当前工具命中区域选择文本、拖动或默认系统光标。
// shell/app 更新原生光标时调用;Sources 文本区域优先返回 I-beam,其余用同帧 Widget 状态映射。
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))
{
// 只读源码选择区先于普通 Widget 光标,显示 I-beam 并不赋予源码可编辑或可执行的能力。
return CursorIcon::Text;
}
// 宿主抽象 Cursor 转换成 winit CursorIcon,保留禁用、抓取与横纵调整等语义。
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,
}
}
/// 委托宿主控件处理指针离开,具体捕获是否保留由 Interaction 决定。
// shell/app 收到原生指针离开窗口时调用,委托 Interaction 清理离开状态,不清空输入文本。
pub fn pointer_left(&mut self) {
self.interaction.pointer_left();
}
/// 清理指针或焦点离开后的悬停/捕获状态,避免按下状态残留。
// 工具切换和窗口焦点转移路径调用,清焦点/拖动/preedit,保留输入草稿、历史及滚动。
pub fn blur(&mut self) {
self.interaction.blur();
self.drag = None;
self.focus = None;
self.preedit.clear();
}
/// 记录按下时的控件或文本选择目标,为释放激活和拖动保留捕获。
// shell/app 路由到工具的鼠标按下入口;仅当前打开面板内输入能建立控件捕获或源码拖选。
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);
// 先交给同帧 Widget 集合解析,滚动条和分隔条等控件命中优先于大面积源码文字区域。
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 {
// 细分隔线的交互矩形向四周扩 3 单位,布局条目提供拖动类别而不是用屏幕位置猜测。
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;
// 所有确认的控件动作集中处理;绘制模块只构造 Action,不在创建控件时执行请求或主题更新。
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);
// 累计前面各行的 UTF-8 字节长度及换行字节,行内测得 offset 后再合成整个 input 的 cursor。
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();
// 与 Sources 的中点算法不同,这里取首个末端宽度不小于目标的字素起点。
for (i, part) in line.grapheme_indices(true) {
let advance =
text::measure(&line[..i + part.len()], TextStyle::code()).width;
// 找到包含目标 x 的字素即停止;超过全行时 offset 维持行尾。
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();
}
// 清屏同时取消详情选择,但不改变共享日志或 Console 的清屏水位。
Some(Action::ClearNetwork) => {
self.network_after = activity.lock().unwrap().records.back().map_or(0, |r| r.id);
self.selected_request = None;
}
// 切换后清输出指纹,使下一次 Console 构建重新判断跟随;滚动操作本身在绘制时完成。
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(),
// provider 同步读取结果保存在 cloud_sources,无论成功与否都把源码滚动移回起点。
Some(Action::CloudOpen(id)) => {
self.cloud_sources.open(&id);
self.scroll[Region::Source as usize] = Scroll::default();
}
// 选中 AST 身份并跳到一基起始行,saturating_sub 兼容零值而不发生无符号下溢。
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 {
// 点击 Console 的请求标题转到该状态的 Network 视图,清筛选/水位让目标记录可见;不重新发送请求。
self.tab = Tab::Network;
self.network_after = 0;
self.filter.clear();
}
self.scroll[Region::Details as usize] = Scroll::default();
}
// 索引来自 PALETTE 色块生成的动作,直接更新强调色并把自定义编辑文本同步成六位 hex。
Some(Action::Theme(i)) => {
self.accent = PALETTE[i];
self.custom = format!(
"#{:02X}{:02X}{:02X}",
self.accent.red, self.accent.green, self.accent.blue
);
}
None => {}
}
}
/// 结束捕获并按同一目标/启用条件决定是否执行操作。
// shell/app 的鼠标抬起入口;先由 Interaction 验证捕获目标,再统一结束工具拖动与源码拖选。
pub fn release(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) {
self.pointer = p;
self.interaction.pointer_moved(p, &layout.widgets);
// release 只为原先捕获且仍启用的同目标返回 Action,不能按当前鼠标位置随意激活另一个按钮。
let action = self.interaction.release(&layout.widgets);
self.drag = None;
self.source_selection.dragging = false;
// 只有返回有效动作才 activate,否则只释放捕获,不因抬起点恰好碰到别的控件触发它。
if action.is_some() {
self.activate(action, layout, p, activity);
}
}
/// 按工具控件焦点处理 Tab、确认等管理键盘交互。
// shell/app 在普通编辑 key 前调用;返回 true 表示已经消费控件导航/激活,不再落入 Console 文本逻辑。
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;
}
// 没有文本焦点时 Escape 清宿主控件焦点;按下和抬起都消费,只有按下执行 blur。
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 {
// 仅非重复按下推进焦点;目标含 Focus 动作才映射成文本焦点,否则保留控件导航焦点。
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) {
// 键盘导航进入 Console 编辑器时将插入点放到当前草稿末尾,再由下一帧测量并滚到可见范围。
self.cursor = self.input.len();
self.reveal_cursor();
}
}
return true;
}
// Enter/Space 仅激活非 TextField 控件,文本输入的这些键继续交 key 处理。
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
}
/// 更新工具内悬停、拖动、选择或分隔条位置。
// shell/app 每次指针移动传入当前位置和增量,更新悬停、源码拖选及已捕获拖动;不重新选择 Drag 目标。
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 {
// 树宽至少 120,且尽量给右侧源码保留 180;窄窗口通过 max 保持 clamp 上下界合法。
Some(Drag::Split(Split::Tree)) => {
self.tree_width =
(p.x - layout.panel.x).clamp(120.0, (layout.panel.width - 180.0).max(120.0))
}
// 把指针 y 转成上下区比例并限于 20%..80%,避免拖动把某个区域压到完全不可访问。
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 {
// 捕获时保存的 h 决定只改横向偏移,ratio 将手柄逻辑位移转换成内容位移。
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 {
// 用户主动拖动输出区后停用 Follow,否则下一帧会立即把刚选择的阅读位置拉回底部。
self.follow = false;
}
}
None => {}
}
}
/// 将滚轮作用到当前工具的可滚动区域,钳制本工具偏移。
// shell/app 路由工具区域滚轮时调用;按逆区域顺序命中一个面板,内容上界交下一次 Painter 统一计算。
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))
{
// r 来自本帧命中区域,选择该工具的独立滚动槽;不改变其他面板或其他 Workspace 页的偏移。
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 {
// 滚轮与手柄拖动采用同样的 Follow 取消规则,下一轮 Painter 再钳最新内容上界。
self.follow = false;
}
}
}
/// 在当前选择/光标处插入输入文本,并更新光标与可见范围。
// 普通文本输入、IME Commit、粘贴及剪切成功后的清空共用;Source 焦点与无焦点不接受写入。
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;
}
// Input 全选替换只在预算检查通过后清原文,因此超大粘贴失败仍保留已有输入。
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;
}
// 自定义色是编辑文本,允许的字符过滤不等于完整 hex 验证,Enter 才应用主题。
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;
}
/// 返回输入法候选窗应跟随的逻辑光标位置。
// shell/app 更新原生输入法候选位置时调用,使用本帧 Console 输入原点和正式 input 的 cursor,不追加 preedit。
pub fn ime_position(&self, layout: &DeveloperToolsLayout) -> Point {
if let Some(input) = layout.input {
// cursor 由编辑路径保持在有效字节边界,当前行前缀测量用于候选窗跟随,不按字符数估算 x。
let before = &self.input[..self.cursor];
let row = before.matches('\n').count() as f32;
// col 只是当前行在光标之前的文本,按真实字形宽度测量;area 给候选窗位置的可见钳制范围。
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),
);
}
// 没有输入布局时返回工具内固定候选位置,不通过空 input 几何构造无效坐标。
Point::new(layout.panel.x + 20.0, 60.0)
}
/// 取得当前工具文本选择内容,供复制命令使用。
// shell/app 的复制/剪切快捷键调用,返回 String 后才交系统剪贴板;Source 只读插入不会修改显示源码。
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 光标/选择边界。
// shell/app 处理命名编辑键调用;输入法组合期间外层已过滤原始编辑键,提交文本走 insert。
pub fn key(&mut self, key: &winit::keyboard::NamedKey, shift: bool, command: bool) {
use winit::keyboard::NamedKey::*;
// Escape 在任何文本焦点先处理,结束输入组合与宿主控件焦点,不提交 Console 草稿。
if *key == Escape {
self.focus = None;
self.preedit.clear();
self.interaction.blur();
return;
}
// 源码使用独立 SourceSelection 导航,不把只读源码端点写成 Console cursor;成功移动安排重绘。
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 {
// 允许开头一个或多个 # 被剥除,余下必须恰好六个 ASCII hex 字节;不是单纯按输入字符过滤就接受。
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;
}
// 其他文本框仅处理 Backspace,删除末尾 Unicode 标量或全选内容,没有独立光标导航。
if self.focus != Some(Focus::Input) {
if *key == Backspace {
match self.focus {
Some(Focus::Filter) => {
if self.selection {
// Filter 全选时删整框,否则下方 pop 删除末尾 Unicode 标量;这里没有独立字节光标。
self.filter.clear();
} else {
self.filter.pop();
}
}
Some(Focus::Custom) => {
if self.selection {
// 颜色编辑框遵循同样的全选删除规则,删除文本本身不会立即应用或重置强调色。
self.custom.clear();
} else {
self.custom.pop();
}
}
_ => {}
}
self.selection = false;
}
return;
}
// Console 的 Enter/Tab/Space、删除和导航共用字节 cursor;末尾统一请求下一帧显示光标。
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 {
// 光标已在有效字节边界,向前查最后一个完整字素的起点,再删除到当前 cursor 的整个区间。
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, "");
}
}
// command 直接到输入开头,普通按键退一个字素;这套 Console 选择当前只支持全选,移动会取消它。
ArrowLeft => {
self.cursor = if command {
0
} else {
self.input[..self.cursor]
.grapheme_indices(true)
.next_back()
.map_or(0, |(i, _)| i)
};
self.selection = false;
}
// command 直接到输入末尾,否则加后缀第一个完整字素的字节长度,行分隔也按文本字素遍历。
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 使用当前行前一个换行之后的位置,command 则到整份输入的字节 0。
Home => {
self.cursor = if command {
0
} else {
self.input[..self.cursor].rfind('\n').map_or(0, |i| i + 1)
};
self.selection = false;
}
// 普通 End 找当前 cursor 后首个换行,command 则用完整字符串长度。
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())
};
// 仅真实历史下标存 Some,末端用 None 表示草稿;恢复文本后光标统一放到末尾。
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();
// 求相邻行的起始字节位置;第一行向上取 0,末行向下取文本末尾。
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 实现负责。
// shell/app 首次打开或请求工具入口时调用,后台向当前 app.main REPL 发送能力调用,不在 GUI 线程操作 VM。
pub fn initialize_tools(&mut self, name: &str) {
// 把名称与代理克隆进后台线程,线程不借用 Workspace 或活动 Window 生命周期。
let name = name.to_owned();
let proxy = self.proxy.clone();
thread::spawn(move || {
// 用调试字符串转义名称生成 Endor 文本,再经本地 REPL 执行;实际工具白名单由宿主能力验证。
let source = format!("ui.developerTools({name:?})");
// 连接本机固定 API,读取响应直到服务端关闭;这里只检查 I/O 成功,不解析 HTTP 状态或结果 JSON。
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
)?;
// 响应只用于读完连接,不在此解析状态码/JSON;HTTP 422 等应用错误仍由工具活动记录显示。
let mut response = String::new();
stream.read_to_string(&mut response)?;
Ok(())
};
// 传输失败且有事件代理时通知 ConsoleFailed;初始化调用没有输入草稿,source 传空串。
if let Err(error) = send()
&& let Some(proxy) = proxy
{
// 仅传输失败走原生错误通知,source 为空表示初始化工具没有可恢复的用户输入草稿。
let _ = proxy.send_event(AppEvent::ConsoleFailed {
message: error.to_string(),
source: String::new(),
});
}
});
}
/// 把当前工具输入按所选模式提交给本地 API,并异步反馈结果。
// Console key 的不带 Shift Enter 使用此入口,模式固定为普通持续会话提交。
fn submit(&mut self) {
self.submit_console(false);
}
/// 提交 Console 输入到持久运行时,而不是把全部历史重新执行一遍。
// 普通 Enter 和 ResetSession 共用,reset 选择 DELETE,普通提交用 POST,二者只发送当前输入一次。
fn submit_console(&mut self, reset: bool) {
// 空白输入不提交,reset 即使没有输入也有效;返回前不改已有输入/历史状态。
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 展示。
// 下方紧凑闭包只负责 HTTP 传输:2 秒连接、120 秒读取和 10 秒写入超时,响应正文不在客户端解析。
let spawned=thread::Builder::new().name("tessera-console-client".into()).spawn(move||{
let send=||->std::io::Result<()> {
// stream 是本次后台请求独占的 TCP 连接,固定本机端点;不持 Workspace/Namespace 锁,也不在 GUI 线程等待。
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)))?;
// reset 发 DELETE 且正文为空,普通输入发 POST;Content-Length 使用 UTF-8 字节数,文本只作为正文传一次。
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)?;
// read_to_string 只等待响应流结束,不把 HTTP 状态或 value/rendered 字段解释成客户端执行成功。
let mut response=String::new();stream.read_to_string(&mut response)?;Ok(())
};
// 传输错误才带原 source 回到原生线程;app 仅在编辑框仍为空时恢复,避免覆盖用户后来输入的草稿。
if let Err(error)=send()&&let Some(proxy)=proxy{let _=proxy.send_event(AppEvent::ConsoleFailed{message:error.to_string(),source});}
});
// 按线程创建结果更新 GUI 状态,不等待网络或 VM 执行;失败创建线程时保留输入并显示错误。
match spawned {
// 发送线程启动成功才清输入并加入最多80条历史;之后若网络失败由 ConsoleFailed 反馈,不能把清框当执行成功。
Ok(_) => {
if !reset {
self.history.push(self.input.clone());
// 只留最近 80 次已启动提交,超额从最旧项移除;这与共享 HTTP 活动日志的淘汰分别记账。
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(),
}
}
}
/// 按当前工具生成设备比例显示列表与逻辑坐标命中布局,绘制后校正跨帧焦点和捕获。
// scene::build_scene 传入设备像素宽高和比例,返回设备显示列表加逻辑命中布局,外层再加侧栏横向偏移。
pub fn build(
width: u32,
height: u32,
scale: f32,
doc: &RenderDocument,
state: &mut DeveloperToolsState,
activity: &Activity,
) -> (DisplayList, DeveloperToolsLayout) {
// 无工具页面时不读取活动日志或生成控件,空 layout 同时移除当前可命中区域。
if !state.open {
return (Default::default(), Default::default());
}
// 布局和命中统一用逻辑像素,最后仅显示列表按设备 scale 转换,返回 layout 仍为逻辑坐标。
let width = width as f32 / scale;
let height = height as f32 / scale;
// w/x 只是本帧工具局部画布的宽度和起点别名,侧栏偏移由 scene 外层平移,不在每个子工具里重复扣除。
let w = width;
let x = 0.0;
let panel = Rect::new(x, 0.0, w, height);
// Painter 克隆当前 Interaction 供控件外观和目标生成;最后 reconcile 在原 state 上清理失效焦点。
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;
// 仅当前选中的子工具构建内容,共用这次 records 快照和 Painter,底栏与分隔条仍由本入口收尾。
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.
// 分隔条最后绘制/注册,命中优先级与可见叠放相同,细线扩展命中区不会被后画的背景遮住。
// 逆登记顺序绘制覆盖层,拖动 Widget ID 包含 Split 类别,与布局条目保持同一身份。
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();
// 只转换当前宿主管理 UI 实际生成的变体,其他命令原样克隆;新增变体若含坐标需检查这里的 scale 规则。
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)
}