Files
endorphin-tessera-gui/src/shell/app.rs

1669 lines
87 KiB
Rust
Raw Permalink 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.
//! winit 原生应用生命周期和窗口事件入口。这里创建窗口、发布逻辑视口、转发输入,并把不可变文档画到原生表面。
//! 窗口线程不执行 Endor 业务回调;共享状态通过锁读取,实际 VM 更新由 runtime worker 串行完成。
//! 侧栏、页面与开发工具有各自命中区域。拖动捕获必须保留最初的视口目标,不能随指针经过其他组件而跳转。
//!
//! main 调用 new 后交 winit 事件循环的 run_app 驱动:resumed 建立原生资源,window_event 处理 OS 输入,user_event 接收 worker/工具通知。
//! DocumentUpdated 先协调子窗口和一次性窗口命令,再更新焦点令牌;RedrawRequested 才读取完整文档生成显示列表并 present。
//! 主窗口 NamespaceState 是 worker 的共享发布入口,子窗口本地状态只是该窗文档投影;所有窗口的 event_client 仍通向同一个权威队列。
//! OS WindowId、运行时窗口 resource、VM session、layout_version 和 Mount ID 各有用途,不能只凭屏幕位置或某个数字相同认定同一目标。
use std::{
num::NonZeroU32,
sync::{Arc, RwLock},
time::Duration,
};
use softbuffer::{Context, Surface};
use winit::{
application::ApplicationHandler,
dpi::{LogicalPosition, LogicalSize, PhysicalPosition},
event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, EventLoopProxy},
keyboard::{Key, ModifiersState, NamedKey},
window::{Window, WindowAttributes, WindowId},
};
use crate::{
activity::Activity,
developer_tools::Workspace as DeveloperToolsState,
document::NamespaceState,
geometry::Point,
scene::{self, SceneLayout},
software_renderer::SoftwareRenderer,
};
#[derive(Clone, Debug)]
/// worker 到原生事件循环的通知,携带更新/错误/退出状态而不是可执行 VM 引用。
// main 创建的 EventLoopProxy 被 worker、事件客户端和工具 HTTP 线程克隆,ApplicationHandler::user_event 统一接收。
pub enum AppEvent {
RuntimeFailed { message: String },
DocumentUpdated,
ShutdownComplete { error: Option<String> },
ConsoleFailed { message: String, source: String },
}
/// 一个原生窗口的宿主状态;主窗口还协调子窗口资源,共用同一运行时与输入通道。
pub struct TesseraApp {
// 运行时窗口资源编号与 winit WindowId 分属不同命名空间,输入携带前者,OS 事件匹配后者。
resource: u64,
// 共享运行时的退出/调度控制,所有原生窗口复用;OS 关闭需要先走运行时生命周期收尾。
control: crate::api::RuntimeControl,
exit_requested: bool,
// 记录当前原生窗口所对应的运行时世代,避免重启后继续显示已失效的子窗口。
native_session: u64,
// 防止初始无文档时反复申请起始页面,与开发工具输入提交状态分开。
startup_page_requested: bool,
native_error: Option<String>,
// 只有主窗口维护此表;子窗口有独立文档/绘制状态,但复用运行时控制与输入队列。
children: std::collections::BTreeMap<u64, TesseraApp>,
proxy: EventLoopProxy<AppEvent>,
// 窗口、softbuffer 上下文与表面延迟创建;Arc 让表面持有有效的原生窗口句柄。
window: Option<Arc<Window>>,
context: Option<Context<Arc<Window>>>,
surface: Option<Surface<Arc<Window>, Arc<Window>>>,
// 主窗口连接 worker 权威发布状态,子窗口保存从其投影的本地副本;输入客户端仍共享权威队列。
namespace: Arc<RwLock<NamespaceState>>,
developer_tools: DeveloperToolsState,
// 最近一次绘制生成的宿主控件几何,用于下一次输入命中、光标和 IME 定位。
layout: SceneLayout,
pointer: Point,
sidebar: crate::sidebar::State,
// 最近交互是否把键盘路由给侧栏控件,决定 Tab/Enter/Space 是否先被宿主导航消费。
sidebar_keyboard: bool,
panning: bool,
// 拖动开始时捕获视口,None 也可能表示拖动整张 Canvas,而非手势未开始。
pan_viewport: Option<u64>,
orbiting: bool,
space_down: bool,
modifiers: ModifiersState,
activity: Activity,
event_client: crate::events::Client,
// 保存按下时的版本与位置;释放时需确认仍处在同一会话与布局,防止旧画面误点击。
guest_pressed: Option<crate::events::Input>,
// 页面指针悬停和捕获分别存带版本令牌;按下捕获后不能随着指针移到别的出现而切目标。
pointer_hover: Option<crate::events::Input>,
pointer_capture: Option<crate::events::Input>,
// 已发布帧的页面焦点令牌,含会话/布局版本;按键不能仅凭旧 Mount ID 发到新世代。
guest_focus: Option<crate::events::Input>,
// Enter/Space 的按下捕获;只在对应按键释放时发出一次 click,忽略系统连发。
guest_key: Option<(NamedKey, crate::events::Input)>,
guest_keyboard: bool,
}
impl TesseraApp {
/// 先建立连接权威 namespace 的有界事件客户端,再初始化主窗口状态。
/// 客户端创建失败向调用方返回错误;原生窗口和像素表面延迟到 resumed 创建。
// main 在启动原生 run_app 前调用;只建立输入客户端与状态,实际 OS 窗口依赖事件循环 resumed。
pub fn new(
namespace: Arc<RwLock<NamespaceState>>,
activity: Activity,
proxy: EventLoopProxy<AppEvent>,
control: crate::api::RuntimeControl,
) -> anyhow::Result<Self> {
// 初始化可能启动/连接宿主事件发送设施,失败就停止应用构造,不能保留无权威输入通道的半初始化窗口。
let event_client =
crate::events::Client::new(proxy.clone(), namespace.clone(), control.clone())?;
Ok(Self::with_event_client(
namespace,
activity,
proxy,
control,
event_client,
))
}
/// 建立窗口宿主状态并复用权威输入客户端,避免子窗口使用滞后的独立事件队列。
// new 创建主状态、reconcile_windows 创建子状态时共用;子实例随后覆盖 resource 和 native_session。
fn with_event_client(
namespace: Arc<RwLock<NamespaceState>>,
activity: Activity,
proxy: EventLoopProxy<AppEvent>,
control: crate::api::RuntimeControl,
event_client: crate::events::Client,
) -> Self {
// 窗口表面、会话身份、焦点和手势均从空状态开始,侧栏与开发工具各有独立宿主状态。
Self {
resource: crate::window::MAIN_WINDOW,
control,
exit_requested: false,
native_session: 0,
startup_page_requested: false,
native_error: None,
children: std::collections::BTreeMap::new(),
proxy: proxy.clone(),
window: None,
context: None,
surface: None,
namespace,
developer_tools: DeveloperToolsState::new(proxy),
layout: SceneLayout::default(),
pointer: Point::default(),
sidebar: crate::sidebar::State::default(),
sidebar_keyboard: false,
panning: false,
pan_viewport: None,
orbiting: false,
space_down: false,
modifiers: ModifiersState::default(),
activity,
event_client,
guest_pressed: None,
pointer_hover: None,
pointer_capture: None,
guest_focus: None,
guest_key: None,
guest_keyboard: true,
}
}
/// 将已发布窗口文档与原生资源对齐;session 更换时丢弃旧域的子窗口。
// document_updated 的第一阶段调用;只让主实例协调子窗口,子实例收到更新时直接跳过协调。
fn reconcile_windows(&mut self, event_loop: &ActiveEventLoop) {
if self.resource != crate::window::MAIN_WINDOW {
return;
}
// 共享状态读锁失败时保留现有原生窗口,本次不根据不完整信息删除或创建子资源。
let Ok(namespace) = self.namespace.read() else {
return;
};
// 同时复制会话、发布版本和提交编号,所有子文档快照使用同一批父发布元数据。
let session = namespace.runtime_session;
let revision = namespace.revision;
let submission = namespace.submission;
// 先复制已发布的子窗口投影并释放读锁,再创建/销毁 OS 资源;原生调用不会占着发布锁。
let documents = namespace.document.windows.clone();
drop(namespace);
// 运行时世代变化不必立即销毁所有窗口,下面先检查发布投影是否已经属于新世代。
if session != self.native_session {
// 控制台 reset 可以保留旧投影但禁用输入;真正替换运行时后,
// 新文档带新 session,此时销毁旧原生视图,防止跨世代复用。
let replaces = documents
.values()
.any(|document| document.frame.session == session && session != 0);
// 新世代已有有效子文档或明确无子窗口时清旧资源;session=0 的暂留投影不在此分支强制清空。
if replaces || (session != 0 && documents.is_empty()) {
self.children.clear();
}
self.native_session = session;
}
// 逐资源 ID 与新文档对照,缺失的窗口删除;session=0 投影允许保留旧窗口用于显示。
self.children.retain(|id, child| {
documents.get(id).is_some_and(|document| {
document.frame.session == 0 || child.native_session == document.frame.session
})
});
// 既有子窗口更新数据后刷新,新资源则建立独立宿主状态;不为每个子窗口创建另一套 VM。
for (id, document) in documents {
if let Some(child) = self.children.get_mut(&id) {
// 本地子状态整体替换文档和发布元数据,再由 child.document_updated 应用该窗口焦点及原生命令。
if let Ok(mut namespace) = child.namespace.write() {
namespace.document = document;
namespace.runtime_session = session;
namespace.revision = revision;
namespace.submission = submission;
}
child.document_updated(event_loop);
} else {
// 新子窗口的 NamespaceState 只装本窗口投影;event_client 仍复用主客户端以校验权威会话和布局。
let namespace = Arc::new(RwLock::new(NamespaceState {
document,
runtime_session: session,
revision,
submission,
..NamespaceState::default()
}));
// 新建的是宿主窗口状态,共享活动日志/控制/输入客户端;不会在此为副窗口建立另一套解释器。
let mut child = Self::with_event_client(
namespace,
self.activity.clone(),
self.proxy.clone(),
self.control.clone(),
self.event_client.clone(),
);
// 把默认主资源号改成本次子窗口 ID,后续输入、viewport 和原生事件由该编号归属。
child.resource = id;
child.native_session = session;
child.resumed(event_loop);
// 原生创建失败将错误放到主工具界面,不把缺失 OS 窗口表述成运行时创建已成功呈现。
if let Some(error) = &child.native_error {
self.developer_tools.input_error = error.clone();
self.developer_tools.open = true;
}
child.document_updated(event_loop);
self.children.insert(id, child);
}
}
}
/// 只应用当前运行时身份的窗口命令;执行前从共享队列取走,避免重复执行。
// 文档更新和 resumed 路径调用;消费一次性窗口动作,主关闭转退出请求而非直接终止 VM。
fn apply_window_commands(&mut self) {
let Ok(mut namespace) = self.namespace.write() else {
return;
};
let session = namespace.runtime_session;
// 取走整批命令即表示消费;随后释放写锁,执行窗口 API 时不阻塞 worker 的文档发布。
let commands = std::mem::take(&mut namespace.window_commands);
drop(namespace);
// 脱离锁后逐条检查世代;整个命令批已取走,过期/无目标命令会丢弃而不是留队重试。
for envelope in commands {
// 只有当前非零会话的命令可操作 OS 资源,旧域延迟发布的动作不能影响新窗口。
if envelope.session == 0 || envelope.session != session {
continue;
}
let id = envelope.command.window;
// 关闭优先处理:主资源进入受控退出,子资源从原生表移除,然后不再落到普通窗口动作分支。
if matches!(envelope.command.action, crate::window::Action::Close) {
if id == crate::window::MAIN_WINDOW {
self.request_exit();
} else {
self.children.remove(&id);
}
continue;
}
// 资源 ID 查本窗口或主实例管理的子窗口,查不到实际 Window 时直接跳过。
let target = if id == self.resource {
self.window.as_ref()
} else {
self.children
.get(&id)
.and_then(|child| child.window.as_ref())
};
let Some(window) = target else {
// 该批已一次性消费,没有 OS 句柄的目标不会留到以后按同编号重试,避免影响后来创建的资源。
continue;
};
// 尺寸使用逻辑单位,桌面位置使用物理屏幕坐标;这些 OS 操作都在原生事件线程执行。
match envelope.command.action {
crate::window::Action::Resize { width, height } => {
// 向窗口管理器发逻辑尺寸请求,返回值不当作最终 viewport;批结束后重新读取真实窗口大小。
let _ = window.request_inner_size(LogicalSize::new(width, height));
}
crate::window::Action::SetTitle(title) => window.set_title(&title),
crate::window::Action::MoveTo { x, y } => {
// 以平台能否读取外部位置作为可移动性检查,不支持时通过 RuntimeFailed 展示错误。
if window.outer_position().is_ok() {
window.set_outer_position(PhysicalPosition::new(x, y));
} else {
let _ = self.proxy.send_event(AppEvent::RuntimeFailed {
message: format!(
"Window {id}: desktop placement is unsupported on this platform"
),
});
}
}
crate::window::Action::Focus => window.focus_window(),
crate::window::Action::Close => {}
}
}
// 动作批执行后重新读取实际尺寸并发布,不能把 request_inner_size 的请求尺寸直接当成 OS 最终分配。
self.publish_viewport();
}
/// 将真实原生窗口尺寸换算成扣除侧栏的逻辑页面尺寸,并发布给 worker。
// 创建、尺寸/比例变化、侧栏折叠和窗口命令收尾共用,页面可用宽度始终扣除当前侧栏占用。
fn publish_viewport(&self) {
let Some(window) = &self.window else {
return;
};
// OS 返回设备像素,先按当前 scale_factor 转逻辑尺寸,再检查有效性和布局预算。
let size = window.inner_size().to_logical::<f32>(window.scale_factor());
// 最小化或无效尺寸不覆盖已知 viewport;避免把零面积/非有限值作为正常布局约束交 worker。
if size.width <= 0.0
|| size.height <= 0.0
|| !size.width.is_finite()
|| !size.height.is_finite()
{
return;
}
if let Ok(mut namespace) = self.namespace.write() {
// 页面区域只横向减侧栏,工具是否打开由文档页决定;两轴上限受 UI 最大尺寸约束。
let size = crate::layout::Size {
width: (size.width - self.sidebar.left())
.max(0.0)
.min(crate::ui_bridge::MAX_DIMENSION as f32),
height: size.height.min(crate::ui_bridge::MAX_DIMENSION as f32),
};
namespace.viewport = Some(size);
namespace.window_viewports.insert(self.resource, size);
if self.resource == crate::window::MAIN_WINDOW {
let session = namespace.runtime_session;
// 主窗口维护 worker 可见的视口总表:先移除旧世代/已关闭子窗口,再复制当前有效子窗口的逻辑尺寸。
namespace.window_viewports.retain(|id, _| {
*id == crate::window::MAIN_WINDOW
|| self
.children
.get(id)
.is_some_and(|child| session != 0 && child.native_session == session)
});
// 主表只汇总当前非零世代且子文档也属于该世代的尺寸,防止旧原生窗口覆盖新域约束。
for (id, child) in &self.children {
if child.native_session == session
&& session != 0
&& let Ok(local) = child.namespace.read()
&& local.document.frame.session == session
&& let Some(size) = local.viewport
{
namespace.window_viewports.insert(*id, size);
}
}
}
}
}
/// 仅请求原生事件循环安排重绘,不在调用处立即递归绘制。
// 输入、文档及工具状态变化共用;window 尚未创建时自然跳过,不在这里分配像素缓冲。
fn request_redraw(&self) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
/// 通过托管开发工具入口打开指定工具页面,保留对应窗口上下文。
// 快捷键、侧栏工具入口与启动逻辑共用;已有会话走带窗口身份事件,无会话才通过初始化 REPL 请求工具。
fn open_developer_tool(&mut self, name: &str) {
// 在一次读锁里保存本帧会话和布局版本,工具事件 mount=0,实际能力由 worker 解析执行。
let input = self.namespace.read().ok().map(|namespace| {
let frame = &namespace.document.frame;
crate::events::Input {
window: self.resource,
session: frame.session,
layout_version: frame.layout_version,
mount: 0,
kind: format!("developer-open:{name}"),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
}
});
if let Some(input) = input {
// 初始文档还没有有效运行时令牌,initialize_tools 走本机 API;否则保持事件队列的窗口上下文。
if input.session == 0 {
self.developer_tools.initialize_tools(name);
} else {
self.send_guest(input);
}
}
// 请求工具后先停止业务键盘路由,等文档发布再依据实际活动页重新建立路由和焦点。
self.guest_keyboard = false;
self.request_redraw();
}
/// 接收完整文档更新,协调子窗口和原生命令,再更新输入焦点并请求重绘。
// 主/子窗口发布通知都到此路径,资源同步后从完整 Frame 提取工具标记和页面焦点令牌。
fn document_updated(&mut self, event_loop: &ActiveEventLoop) {
// 先使原生资源对应新文档,再执行其窗口命令;随后重建焦点令牌,避免沿用旧布局身份。
self.reconcile_windows(event_loop);
self.apply_window_commands();
// 重新读取的是资源/命令同步后的完整本窗投影,焦点和工具标记来自这份发布 Frame,而非 GUI 自行猜测。
if let Ok(namespace) = self.namespace.read() {
self.developer_tools.document_updated(&namespace.document);
let frame = &namespace.document.frame;
// 活动开发工具页接管宿主编辑,普通页面才允许后续 handle_guest_key 接收业务键盘。
self.guest_keyboard = !frame
.tabs
.iter()
.any(|tab| tab.active && tab.developer_tool.is_some());
// 只有无故障帧才保存 focus 出现身份,令牌每次附当前 session/layout_version,不复用上帧旧版本。
self.guest_focus =
frame
.focus
.filter(|_| frame.fault.is_none())
.map(|mount| crate::events::Input {
window: self.resource,
session: frame.session,
layout_version: frame.layout_version,
mount,
kind: "focus".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
});
}
self.request_redraw();
}
/// 先处理宿主管理输入、选择与快捷键,再按焦点决定是否交给页面。
// window_event 的 KeyboardInput 分支调用;本函数决定宿主/页面/工具的优先级,实际源回调仍由 worker 执行。
fn handle_keyboard(&mut self, event: &winit::event::KeyEvent) {
// 宿主快捷键优先级最高;后续依次交给侧栏、页面交互、开发工具按钮及文本编辑。
let command = self.modifiers.super_key() || self.modifiers.control_key();
// 命令+B 只在首次按下切侧栏;切换后页面布局约束由 sidebar_action 重新发布。
if command
&& event.state == ElementState::Pressed
&& !event.repeat
&& matches!(&event.logical_key, Key::Character(key) if key.eq_ignore_ascii_case("b"))
{
self.sidebar_action(crate::sidebar::Action::Toggle);
return;
}
// Escape 先停止页面平移和按下捕获,后续仍可交当前工具编辑器处理焦点取消。
if event.logical_key == Key::Named(NamedKey::Escape) {
self.panning = false;
self.space_down = false;
self.guest_pressed = None;
}
// 无页面焦点且页面为 Canvas 时,Space 按住作为平移修饰键,不作为业务或 Console 输入。
if event.logical_key == Key::Named(NamedKey::Space)
&& self.guest_focus.is_none()
&& self
.namespace
.read()
.is_ok_and(|n| n.document.frame.surface.mode == crate::page::Mode::Canvas)
{
self.space_down = event.state == ElementState::Pressed;
if !self.space_down {
// Space 释放结束正在进行的整页/局部平移,不能因漏掉鼠标释放而一直把后续移动当作拖动。
self.panning = false;
}
return;
}
// 侧栏获得键盘路由后,Tab 在宿主目标间移动,Enter/Space 采用按下捕获、释放触发。
if self.sidebar_keyboard {
if event.logical_key == Key::Named(NamedKey::Tab)
&& event.state == ElementState::Pressed
{
self.sidebar
.interaction
.focus_next(&self.sidebar.targets, self.modifiers.shift_key());
self.request_redraw();
return;
}
// 侧栏 Enter/Space 的按下/释放走同一 Interaction 捕获,释放确认动作后再调用 sidebar_action。
if matches!(
event.logical_key,
Key::Named(NamedKey::Enter | NamedKey::Space)
) {
if event.state == ElementState::Pressed {
// 侧栏只保存按下捕获,真正动作等下面对应释放确认;不在按下和抬起各激活一次。
self.sidebar.interaction.key_press(&self.sidebar.targets);
} else if let Some(action) =
self.sidebar.interaction.key_release(&self.sidebar.targets)
{
self.sidebar_action(action);
}
self.request_redraw();
return;
}
}
// 新页快捷键在侧栏键盘路由之后处理;只首次按下发一个工具请求,系统重复不创建多页。
if event.state == ElementState::Pressed
&& !event.repeat
&& (self.modifiers.super_key() || self.modifiers.control_key())
&& matches!(&event.logical_key, Key::Character(key) if key.eq_ignore_ascii_case("t"))
{
self.open_developer_tool("New tab");
return;
}
if event.state == ElementState::Pressed && !event.repeat {
// 主命令+W 关闭当前页,Ctrl+Tab 切页;处理成功才返回,未能生成有效事件时继续其他路由。
let close = (self.modifiers.super_key() || self.modifiers.control_key())
&& matches!(&event.logical_key, Key::Character(key) if key.eq_ignore_ascii_case("w"));
let cycle = self.modifiers.control_key()
&& matches!(event.logical_key, Key::Named(NamedKey::Tab));
// 只有确有关闭/切页动作且能构造当前页事件才消费按键,否则继续页面与工具的普通键盘处理。
if (close || cycle) && self.send_tab_shortcut(close) {
return;
}
}
// 页面交互成功消费按键才提前返回;未处理的按键还可交给开发工具,保持明确的路由顺序。
// 页面键盘处理返回是否消费,已消费就安排重绘并停止,不让同一次按键又编辑工具输入。
if self.guest_keyboard && self.handle_guest_key(event) {
self.request_redraw();
return;
}
// 工具按钮和焦点导航先于普通文本编辑,使 Enter/Space 在控件焦点下执行控件动作。
if self.developer_tools.keyboard_control(
event,
self.modifiers.shift_key(),
&self.layout.developer_tools,
&self.activity,
) {
self.request_redraw();
return;
}
// 抬起事件在上面的捕获路径处理过后就结束,以下快捷键与文本插入只处理按下。
if event.state != ElementState::Pressed {
return;
}
// 统一识别 F12 与平台常见工具快捷键;仅非重复按下发起打开 Console 的请求。
let is_f12 = matches!(event.logical_key, Key::Named(NamedKey::F12));
let is_i =
matches!(&event.logical_key, Key::Character(value) if value.eq_ignore_ascii_case("i"));
let mac_shortcut = self.modifiers.super_key()
&& (self.modifiers.alt_key() || self.modifiers.shift_key())
&& is_i;
let other_shortcut = self.modifiers.control_key() && self.modifiers.shift_key() && is_i;
if is_f12 || mac_shortcut || other_shortcut {
if !event.repeat {
// 快捷键只请求打开 Console;实际工具 Page 由所属 VM 创建/激活,窗口线程不直接分配托管对象。
self.open_developer_tool("Console");
}
return;
}
// 只有展开且有编辑焦点的工具接受文本;剪切先成功写系统剪贴板,再删除选中内容。
if !self.developer_tools.open || self.developer_tools.focus.is_none() {
return;
}
// 重新读取编辑快捷键修饰条件,命令字符在下面单独处理,不作为普通文本插入。
let command = self.modifiers.super_key() || self.modifiers.control_key();
if command && let Key::Character(value) = &event.logical_key {
// 全选只改工具选择状态,粘贴先读系统剪贴板,复制/剪切先取得当前焦点对应的选中文本。
match value.to_lowercase().as_str() {
"a" => self.developer_tools.selection = true,
"v" => match crate::clipboard::read() {
Ok(value) => self.developer_tools.insert(&value),
Err(error) => self.developer_tools.input_error = format!("Clipboard: {error}"),
},
"c" | "x" => {
let selected = self.developer_tools.selected_text();
// 没有可复制文本时不写空剪贴板;剪切也不在写入失败时删除编辑器内容。
if !selected.is_empty() {
match crate::clipboard::write(&selected) {
Ok(()) if value.eq_ignore_ascii_case("x") => {
self.developer_tools.insert("")
}
Ok(()) => {}
Err(error) => {
self.developer_tools.input_error = format!("Clipboard: {error}")
}
}
}
}
_ => {}
}
// 输入法预编辑期间不再把原始按键转换为编辑动作;最终文本通过 Ime::Commit 插入。
} else if let Key::Named(key) = &event.logical_key {
if !self.developer_tools.preedit.is_empty() {
return;
}
self.developer_tools
.key(key, self.modifiers.shift_key(), command);
} else if !command
&& self.developer_tools.preedit.is_empty()
&& let Some(text) = &event.text
{
// 普通按键文本剔除控制字符,换行/Tab 等编辑语义由命名键路径处理,IME Commit 则在 window_event 另行插入。
let text: String = text.chars().filter(|c| !c.is_control()).collect();
self.developer_tools.insert(&text);
}
self.request_redraw();
}
/// 判断指针是否被侧栏或管理面板覆盖,阻止一次点击同时落入页面。
// guest_point 与页面手势/焦点分流共用,普通页面命中先排除宿主逻辑占用区域,而非只排除实际按钮。
fn guest_covered(&self, point: Point) -> bool {
if self.sidebar.covers(point) {
return true;
}
// 开发工具命中区域额外向外扩 6 单位,涵盖分隔线附近,避免边缘拖动同时穿透页面。
let panel = self.layout.developer_tools.panel;
self.developer_tools.open
&& crate::geometry::Rect::new(
panel.x - 6.0,
panel.y - 6.0,
panel.width + 12.0,
panel.height + 12.0,
)
.contains(point)
}
/// 排除宿主管理区域后,把原生指针转换到页面使用的坐标系。
// 普通命中、指针派发和手势入口共用;返回页面 Surface 世界坐标,局部视口/面板逆变换仍由后续几何处理。
fn guest_point(&self) -> Option<Point> {
if self.guest_covered(self.pointer) {
return None;
}
let current = self.namespace.read().ok()?;
// 先扣侧栏逻辑宽度得到页面视图点,不能在 Surface::to_world 后再扣,否则侧栏偏移会被相机缩放。
let point = Point::new(self.pointer.x - self.sidebar.left(), self.pointer.y);
// 在页面视图空间先检查可见分配边界;未发布 viewport 时仅排除负坐标,不擅自推断窗口尺寸。
if point.x < 0.0
|| point.y < 0.0
|| current
.viewport
.is_some_and(|size| point.x >= size.width || point.y >= size.height)
{
return None;
}
Some(current.document.frame.surface.to_world(point))
}
/// 从页面坐标与当前帧命中结果构造带版本的点击输入。
// 鼠标按下/释放、指针订阅入口和光标显示共同读取当前帧的粗命中,不在 GUI 内调用源 hitTest。
fn guest_hit(&self) -> Option<crate::events::Input> {
let point = self.guest_point()?;
let current = self.namespace.read().ok()?;
let frame = &current.document.frame;
if frame.session == 0 {
// reset/失活后仍可显示旧静态文档,但没有活 VM 身份时不能从它构造可执行点击令牌。
return None;
}
// 命中的是当前发布帧,事件同时携带 session/layout_version;worker 可拒绝排队期间已过期的输入。
// Frame 先通过空间候选与原生 contains 选择交互出现,真正源命中及按下/释放比较留给 worker。
let mount = frame.hit_test(point)?;
Some(crate::events::Input {
window: self.resource,
session: frame.session,
layout_version: frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: Some([point.x, point.y]),
press_point: Some([point.x, point.y]),
delta: None,
tab_drop: None,
screen_position: None,
})
}
/// Pointer subscriptions explicitly opt a Mount into continuous input.
/// Capture retains window/session/Mount; the worker validates it again.
/// 左键捕获和悬停移动共用,从粗命中出现向祖先寻找 pointer 订阅,不把所有交互目标都当作连续指针接收者。
fn pointer_target(&self) -> Option<crate::events::Input> {
let mut input = self.guest_hit()?;
// 这里只沿本窗最新出现树找订阅祖先,保留命中令牌的窗口/会话/版本;执行端仍需再次核验。
let current = self.namespace.read().ok()?;
let frame = &current.document.frame;
let mut mount = frame.mounts.iter().find(|m| m.id == input.mount)?;
// 当前出现没有订阅就沿父链找最近订阅祖先;无父或父项缺失时 None,不穿到其他兄弟。
while !mount.pointer_events {
mount = frame.mounts.iter().find(|m| Some(m.id) == mount.parent)?;
}
// 将事件目标改为订阅出现,清 press_point 以区别普通按下/释放合成 click;会话版本仍来自命中令牌。
input.mount = mount.id;
input.press_point = None;
Some(input)
}
/// move_pointer、cancel_pointer 及左键 down/up 调用,沿用捕获的窗口/会话/出现身份,仅替换类型和可用位置。
fn emit_pointer(&self, mut input: crate::events::Input, kind: &str) {
input.kind = kind.into();
input.press_point = None;
// 能取得当前页面点就更新,否则保留令牌旧 point;离开宿主可转换区域时也能发送取消/释放到原目标。
if let Some(p) = self.guest_point() {
input.point = Some([p.x, p.y]);
}
self.event_client.send(input);
}
/// CursorLeft 和失焦时调用,take 消费捕获与悬停,分别发 cancel/leave,避免下一次清理重复发送。
fn cancel_pointer(&mut self) {
if let Some(input) = self.pointer_capture.take() {
self.emit_pointer(input, "pointercancel");
}
if let Some(input) = self.pointer_hover.take() {
// 悬停与按下捕获是两份状态,取消捕获后仍须单独告知原悬停目标离开;take 保证重复清理不重发。
self.emit_pointer(input, "pointerleave");
}
}
/// window_event 的 CursorMoved 在非相机拖动时调用;有捕获优先发原目标,无捕获才重新解析悬停目标。
fn move_pointer(&mut self) {
// 连续移动保留捕获,直到左键释放或取消才 take;不能因越过另一组件而重新选择接收者。
if let Some(input) = self.pointer_capture.clone() {
self.emit_pointer(input, "pointermove");
return;
}
// 计算本次订阅目标,下面只按 (session,mount) 判断悬停更换,不把每次布局版本变化都当成 leave。
let next = self.pointer_target();
if self.pointer_hover.as_ref().map(|i| (i.session, i.mount))
!= next.as_ref().map(|i| (i.session, i.mount))
&& let Some(old) = self.pointer_hover.take()
{
self.emit_pointer(old, "pointerleave");
}
// 新/持续悬停目标收到 pointermove;本函数没有单独构造 pointerenter。
if let Some(input) = next.clone() {
self.emit_pointer(input, "pointermove");
}
self.pointer_hover = next;
}
/// 将标签快捷键转换为当前窗口/帧中的明确页面动作。
// handle_keyboard 处理命令+W 与 Ctrl+Tab 时调用,返回是否已构造并尝试提交有效页面动作。
fn send_tab_shortcut(&mut self, close: bool) -> bool {
let input = self.namespace.read().ok().and_then(|namespace| {
let frame = &namespace.document.frame;
if frame.session == 0 || frame.fault.is_some() || frame.tabs.is_empty() {
// 没有活会话、帧已故障或标签列表为空时都不能生成页动作,尤其不能对空列表取模。
return None;
}
// 无显式 active 时从第一项开始;tabs 已检查非空,后续取模和前向环绕不会除零或下溢。
let active = frame.tabs.iter().position(|tab| tab.active).unwrap_or(0);
// 关闭保留当前索引,切换按 Shift 选择前一项或后一项,包含工具页在内的完整 tabs 顺序。
let index = if close {
active
} else if self.modifiers.shift_key() {
(active + frame.tabs.len() - 1) % frame.tabs.len()
} else {
(active + 1) % frame.tabs.len()
};
Some(crate::events::Input {
window: self.resource,
session: frame.session,
layout_version: frame.layout_version,
mount: frame.tabs[index].id,
kind: if close { "tab-close" } else { "tab-activate" }.into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
})
});
// 切页/关页前丢弃旧鼠标点击和键盘 click 捕获,避免释放事件落入即将变化的页面。
if let Some(input) = input {
self.guest_pressed = None;
self.guest_key = None;
self.send_guest(input);
true
} else {
false
}
}
/// 处理侧栏展开、工具入口和页面管理,尺寸变化还需重新发布可用视口。
// 侧栏鼠标、键盘以及命令+B 调用,UI 侧只调整宿主开合,业务页面操作仍发带版本事件。
fn sidebar_action(&mut self, action: crate::sidebar::Action) {
use crate::sidebar::Action;
// Toggle 属于宿主布局状态;页面激活/关闭与工具入口则转换成 worker 请求,两类动作不能混成直接 VM 调用。
match action {
Action::Toggle => {
self.sidebar.open = !self.sidebar.open;
// 折叠/展开会改变目标集合,立即取消旧捕获并更新键盘归属,视口重新发布触发布局刷新。
self.sidebar.interaction.cancel();
self.sidebar_keyboard = self.sidebar.open;
self.guest_pressed = None;
self.publish_viewport();
}
Action::NewPage => self.open_developer_tool("New tab"),
Action::Tool(name) => self.open_developer_tool(name),
// 普通页按钮携带标签数字身份,通过事件 kind 区分切页与关闭;mount 字段在此承载标签 ID。
Action::Activate(id) | Action::Close(id) => {
let input = self
.namespace
.read()
.ok()
.map(|current| crate::events::Input {
window: self.resource,
session: current.document.frame.session,
layout_version: current.document.frame.layout_version,
mount: id,
kind: if matches!(action, Action::Close(_)) {
"tab-close"
} else {
"tab-activate"
}
.into(),
point: None,
press_point: None,
delta: None,
tab_drop: None,
screen_position: None,
});
// 读锁成功才构造当前窗口页面事件,实际标签身份和会话有效性仍由 worker 校验。
if let Some(input) = input {
self.send_guest(input);
}
}
}
self.request_redraw();
}
/// 在手势开始时捕获具体相机视口 ID,后续拖动保持同一目标。
// 中键、Space+左键及 3D 超阈值拖动调用,返回是否可开始手势,同时保存具体视口或整页选择。
fn begin_pan(&mut self) -> bool {
// 先清上次捕获,None 在成功返回时表示整页相机,不等于没有手势;是否激活另由 panning 保存。
self.pan_viewport = None;
let Some(point) = self.guest_point() else {
return false;
};
// 只借已发布帧选择局部相机,锁外不保留 Mount 引用;pan_viewport 保存的是纯数值出现身份。
let Ok(current) = self.namespace.read() else {
return false;
};
let frame = &current.document.frame;
// 优先命中允许控制的局部相机,普通 Window 没有整页平移能力,其他页面模式可使用整页手势。
self.pan_viewport = frame.camera_at(point).map(|mount| mount.id);
self.pan_viewport.is_some() || frame.surface.mode != crate::page::Mode::Window
}
/// 把窗口位移/缩放转换为捕获视口的纯数据相机事件,处理侧栏和页面视角的尺度差异。
// 拖动、触控捏合及滚轮缩放共用,向 worker 发纯数据相机操作,不在窗口线程修改托管 Camera 属性。
fn page_view(&mut self, kind: &str, delta: [f32; 2]) {
// 未捕获手势时宿主覆盖区直接拒绝,拖动已捕获则允许指针继续经过宿主区域。
if !self.panning && self.guest_covered(self.pointer) {
return;
}
// 此处先得到页面视图坐标,下面闭包才用当前 Surface.to_world 变成页面世界点;不把侧栏偏移带入相机空间。
let point = Point::new(self.pointer.x - self.sidebar.left(), self.pointer.y);
let input = self.namespace.read().ok().and_then(|current| {
let frame = &current.document.frame;
// 故障/无会话一律拒绝;可见边界仅约束未捕获操作,已开始拖动允许跨出页面矩形。
if frame.session == 0
|| frame.fault.is_some()
|| !self.panning
&& (point.x < 0.0
|| point.y < 0.0
|| current
.viewport
.is_some_and(|size| point.x >= size.width || point.y >= size.height))
{
return None;
}
// 先撤销整页 Surface,局部视口收到世界点后再在运行时处理自身相机和面板映射。
let world = frame.surface.to_world(point);
// 拖动沿用捕获 ID,滚轮/缩放则按当前点命中;被捕获的视口若已消失,取消此次输入而不切换目标。
let viewport = if self.panning {
self.pan_viewport.and_then(|id| {
frame.mounts.iter().find(|mount| {
mount.id == id
&& (mount.canvas_viewport
|| mount.scene3d_viewport && mount.camera_controls)
})
})
} else {
frame.camera_at(world)
};
// 原来捕获的是局部视口却已找不到时忽略本次移动,不降级到整页,也不改成指针下的新视口。
if self.panning && self.pan_viewport.is_some() && viewport.is_none() {
return None;
}
// 局部 2D/3D 视口使用不同动作名。平移除去页面 zoom 以还原世界位移,缩放倍率本身不按 zoom 缩放。
// 局部视口携带实际 Mount ID,动作名选择二维 pan/zoom 或三维 pan/orbit/zoom。
if let Some(viewport) = viewport {
return Some(crate::events::Input {
window: self.resource,
session: frame.session,
layout_version: frame.layout_version,
mount: viewport.id,
kind: if viewport.scene3d_viewport {
// 同一入口按目标能力选择 scene-zoom/orbit/pan,二维 viewport 不使用三维轨道语义。
if kind == "page-zoom" {
"scene-zoom"
} else if self.orbiting {
"scene-orbit"
} else {
"scene-pan"
}
} else if kind == "page-zoom" {
"viewport-zoom"
} else {
"viewport-pan"
}
.into(),
point: Some([world.x, world.y]),
press_point: None,
// 局部平移量先除页面 zoom;缩放的第一分量是倍率不除 zoom,第二分量仍按现有转换处理。
delta: Some([
delta[0]
/ if kind == "page-pan" {
frame.surface.zoom
} else {
1.0
},
delta[1] / frame.surface.zoom,
]),
tab_drop: None,
screen_position: None,
});
}
// 没有局部相机时才考虑整页变换:普通 Window 不平移,只有 Canvas 接受整页缩放。
if frame.surface.mode == crate::page::Mode::Window
|| kind == "page-zoom" && frame.surface.mode != crate::page::Mode::Canvas
{
return None;
}
Some(crate::events::Input {
window: self.resource,
session: frame.session,
layout_version: frame.layout_version,
mount: 0,
kind: kind.into(),
point: (kind == "page-zoom").then_some([point.x, point.y]),
press_point: None,
delta: Some(delta),
tab_drop: None,
screen_position: None,
})
});
// 产生相机操作即撤销同次普通 click 捕获,避免拖动或缩放后释放又触发业务点击。
if let Some(input) = input {
self.guest_pressed = None;
self.send_guest(input);
}
}
/// 把已经构造的页面输入交给有界客户端,保持 GUI 线程不直接调用 VM。
// 点击、焦点、窗口/页面命令和相机输入共用发送入口;返回失败只展示队列错误,不在 GUI 同步重试执行。
fn send_guest(&mut self, event: crate::events::Input) {
if !self.event_client.send(event) {
self.developer_tools.input_error = "Native event queue is unavailable or full".into();
self.developer_tools.open = true;
}
}
/// 把页面拥有焦点时的键盘动作转换为托管事件,保留按下/释放配对。
// handle_keyboard 在 guest_keyboard 路由启用时调用,当前仅处理命名 Tab/Enter/Space,普通字符不合成业务文本事件。
fn handle_guest_key(&mut self, event: &winit::event::KeyEvent) -> bool {
// 非命名键返回未消费,后续仍可被宿主快捷键或开发工具编辑路径处理。
let Key::Named(key) = &event.logical_key else {
return false;
};
// 首次 Tab 按下选下一焦点出现,抬起或系统重复只根据已有 guest_focus 决定是否消费。
if *key == NamedKey::Tab {
if event.state == ElementState::Pressed && !event.repeat {
// 每次焦点移动读取本窗发布候选,不在上一次保存的几何里按索引盲目选择下一项。
let next = self.namespace.read().ok().and_then(|namespace| {
let frame = &namespace.document.frame;
if frame.session == 0 || frame.fault.is_some() {
// 静态保留画面或故障域不能响应焦点移动,返回 None 让调用方按未消费处理。
return None;
}
// 按发布帧中的挂载顺序遍历可交互目标;Shift+Tab 逆向环绕,尚无焦点时从首/末项进入。
let targets: Vec<_> = frame
.mounts
.iter()
.filter(|mount| mount.interactive)
.collect();
// 没有可交互出现就不构造 focus 事件,也不进入后面的取模索引。
if targets.is_empty() {
return None;
}
// 当前焦点令牌通过出现 ID 在本帧候选中定位;不在列表时按首次进入处理。
let current = self
.guest_focus
.as_ref()
.and_then(|focus| targets.iter().position(|mount| mount.id == focus.mount));
let index = if self.modifiers.shift_key() {
// 逆序首次进入取末项,已有焦点则向前环绕;正序分支首次进入取首项。
current
.map(|i| (i + targets.len() - 1) % targets.len())
.unwrap_or(targets.len() - 1)
} else {
current.map(|i| (i + 1) % targets.len()).unwrap_or(0)
};
Some(crate::events::Input {
window: self.resource,
session: frame.session,
layout_version: frame.layout_version,
mount: targets[index].id,
kind: "focus".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
})
});
// 发焦点请求前清旧键盘按下捕获,本地先保存新令牌;worker 仍检查实际合法性,发送失败会显示错误。
if let Some(next) = next {
self.guest_key = None;
self.guest_focus = Some(next.clone());
self.send_guest(next);
return true;
}
return false;
}
return self.guest_focus.is_some();
}
// 有页面焦点才捕获激活键,按下保存键名和焦点令牌,释放须键名匹配才发一次 click。
if matches!(key, NamedKey::Enter | NamedKey::Space) && self.guest_focus.is_some() {
if event.state == ElementState::Pressed {
if !event.repeat {
// 保存首次按下时的键名与版本令牌,系统连发不能覆盖捕获后在释放时制造额外点击。
self.guest_key = self.guest_focus.clone().map(|focus| (*key, focus));
}
// 释放会取走捕获,保证不会重复激活;仅与原来按下的 Enter/Space 相同才转换成 click。
} else if let Some((pressed, mut input)) = self.guest_key.take()
&& pressed == *key
{
input.kind = "click".into();
self.send_guest(input);
}
return true;
}
false
}
/// 按平台窗口属性创建原生窗口、软件表面和输入法支持,再公布实际逻辑视口。
// ApplicationHandler::resumed 调用,先用局部变量完整创建窗口/上下文/表面,成功后才写回宿主字段。
fn create_native(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
// 重复 resumed 不重复创建 OS 窗口,也不重置当前软件表面和输入状态。
if self.window.is_some() {
return Ok(());
}
// 主子窗口有不同初始标题和逻辑尺寸,共同限制最小/最大分配;运行时窗口命令随后可继续设置。
let attributes = WindowAttributes::default()
.with_title(if self.resource == crate::window::MAIN_WINDOW {
"Tessera".into()
} else {
format!("Endor Window {}", self.resource)
})
.with_inner_size(if self.resource == crate::window::MAIN_WINDOW {
LogicalSize::new(1_280.0, 800.0)
} else {
LogicalSize::new(960.0, 640.0)
})
.with_min_inner_size(LogicalSize::new(720.0, 480.0))
.with_max_inner_size(LogicalSize::new(
crate::window::MAX_WINDOW_DIMENSION,
crate::window::MAX_WINDOW_DIMENSION,
));
#[cfg(target_os = "macos")]
// macOS 用全尺寸透明标题栏配合自绘侧栏,真实交通灯按钮仍由 native_chrome 对齐。
let attributes = {
use winit::platform::macos::WindowAttributesExtMacOS;
attributes
.with_title_hidden(true)
.with_titlebar_transparent(true)
.with_fullsize_content_view(true)
};
// 先创建平台窗口,再建立软件绘图上下文/表面;任何一步失败都向外返回,由 resumed 记录并处理。
let window = Arc::new(event_loop.create_window(attributes)?);
#[cfg(target_os = "macos")]
crate::native_chrome::align_controls(&window);
// Context 与 Surface 都持 Window Arc;这两个局部资源创建成功后才写入 self,失败不会留下半套可重绘字段。
let context = Context::new(window.clone()).map_err(|error| anyhow::anyhow!("{error}"))?;
let surface =
Surface::new(&context, window.clone()).map_err(|error| anyhow::anyhow!("{error}"))?;
// OS 资源都建立成功后开启 IME 并保存 Arc,Context/Surface 也持窗口句柄,避免绘制时窗口已失效。
window.set_ime_allowed(true);
self.window = Some(window.clone());
self.context = Some(context);
self.surface = Some(surface);
self.apply_window_commands();
self.publish_viewport();
window.request_redraw();
self.native_error = None;
Ok(())
}
/// 停止新的交互并请求运行时清理;原生事件循环等待完成通知后退出。
// 主窗口 CloseRequested 和关闭命令调用;正常路径等待 worker ShutdownComplete,再让事件循环退出。
fn request_exit(&mut self) {
// 退出请求成功发送后置位,重复关闭不再创建第二轮清理;发送失败分支自行投递完成错误通知。
if self.exit_requested {
return;
}
self.guest_pressed = None;
self.guest_key = None;
// 正常关闭先请求 VM 清理,等待 ShutdownComplete 再退出 OS 循环;发送失败时自行投递结束通知并保留错误。
if self.control.request_shutdown() {
self.exit_requested = true;
} else {
self.native_error = Some("VM worker unavailable during shutdown".into());
let _ = self.proxy.send_event(AppEvent::ShutdownComplete {
error: self.native_error.clone(),
});
}
}
/// 把原生应用记录的最终错误反馈给 main 的退出结果。
// main 在 run_app 返回后读取最终原生/关闭错误,不能仅凭 OS 循环结束判断应用正常退出。
pub fn exit_result(&self) -> anyhow::Result<()> {
if let Some(error) = &self.native_error {
anyhow::bail!("{error}");
}
Ok(())
}
/// 包装一次实际绘制,统一记录故障并避免对同一错误反复发送通知。
// window_event 的 RedrawRequested 调用,先做 macOS 原生按钮校正,再统一处理 try_redraw 的成功/失败状态。
fn redraw(&mut self) {
#[cfg(target_os = "macos")]
if let Some(window) = self.window.as_ref() {
crate::native_chrome::align_controls(window);
}
// 窗口/表面齐全时成功绘制清旧错误;新错误仅在文本变化时通知,避免每帧重复入错误事件。
match self.try_redraw() {
Ok(()) => {
if self.window.is_some() && self.surface.is_some() {
// try_redraw 无原生资源时也可返回 Ok;这里只在资源齐全时清上一次错误,不据此重复发送文档通知。
self.native_error = None;
}
}
Err(error) => {
// 保留窗口资源编号定位错误,文本相同的连续失败不反复投递 RuntimeFailed 刷屏。
let message = format!("Window {} redraw failed: {error:#}", self.resource);
if self.native_error.as_ref() != Some(&message) {
self.native_error = Some(message.clone());
let _ = self.proxy.send_event(AppEvent::RuntimeFailed { message });
}
}
}
}
/// 读取完整发布文档,生成整窗显示列表,写入原生像素表面并呈现;错误交由外层记录。
// redraw 的实际绘制路径,先生成不可变场景再写像素;不调用 VM 业务代码或编译用户程序。
fn try_redraw(&mut self) -> anyhow::Result<()> {
// 先按现有页面粗命中和侧栏 Widget 判断手形,工具区的具体文本/调整光标在布局生成后再查询。
let guest_cursor = self.guest_hit().is_some()
|| self
.sidebar
.interaction
.hit(&self.sidebar.targets)
.is_some();
// 原生资源尚未建立时跳过,本次返回成功只表示没有可绘制目标,不代表已经 present。
let (Some(window), Some(surface)) = (self.window.as_ref(), self.surface.as_mut()) else {
return Ok(());
};
let size = window.inner_size();
// softbuffer resize 要求非零尺寸,最小化产生零轴时跳过分配和呈现。
let (Some(width), Some(height)) =
(NonZeroU32::new(size.width), NonZeroU32::new(size.height))
else {
return Ok(());
};
let Ok(namespace) = self.namespace.read() else {
// 取不到可靠发布文档时跳过本次重绘,不用局部旧字段拼一份不一致场景,也不把锁故障转换成 VM 调用。
return Ok(());
};
// 读锁保护一次完整文档取样与场景生成;生成后释放锁,像素写入和 present 不再占用 namespace。
let scene = scene::build_scene(
size.width,
size.height,
window.scale_factor() as f32,
&namespace.document,
&mut self.developer_tools,
&self.activity,
&mut self.sidebar,
);
drop(namespace);
// 保存本次逻辑命中布局给后续输入,像素绘制使用 scene.display_list 的设备坐标。
self.layout = scene.layout;
window.set_cursor(if guest_cursor {
winit::window::CursorIcon::Pointer
} else {
self.developer_tools
.cursor_kind(&self.layout.developer_tools)
});
// 跨帧控件 reconcile 或文本选择需要额外帧时消费 repaint 标记,安排下一次原生 RedrawRequested。
if self.developer_tools.repaint {
self.developer_tools.repaint = false;
window.request_redraw();
}
// 候选窗位置来自当前逻辑输入布局,传给 winit 时仍用 LogicalPosition,不重复乘设备比例。
let ime = self
.developer_tools
.ime_position(&self.layout.developer_tools);
window.set_ime_cursor_area(
LogicalPosition::new(ime.x as f64, ime.y as f64),
LogicalSize::new(1.0, 19.0),
);
// 根据真实物理尺寸调整像素表面;逻辑坐标到物理像素的转换已由场景构造中的 scale_factor 负责。
surface
.resize(width, height)
.map_err(|error| anyhow::anyhow!("{error}"))?;
// 取得本次目标缓冲后顺序执行整窗列表,渲染报错就不调用下面 present,不将半幅写入当作成功帧。
let mut buffer = surface
.buffer_mut()
.map_err(|error| anyhow::anyhow!("{error}"))?;
SoftwareRenderer::render(&scene.display_list, &mut buffer, size.width, size.height)
.map_err(anyhow::Error::msg)?;
buffer
.present()
.map_err(|error| anyhow::anyhow!("{error}"))?;
Ok(())
}
}
impl ApplicationHandler<AppEvent> for TesseraApp {
/// 处理 OS 直接结束事件循环的收尾,尝试在明确时限内等待运行时清理。
// winit 最终退出回调,无法再依靠 user_event 等通知;此处只做有时限的运行时关闭等待。
fn exiting(&mut self, _: &ActiveEventLoop) {
// OS 终止可能跳过 CloseRequested;此时循环已在退出,无法再等事件通知。
// 最多阻塞 5 秒等待运行时完成清理,超时保留退出错误而不无限等待。
if !self.control.request_shutdown() {
self.native_error = Some("VM worker unavailable during shutdown".into());
return;
}
// 已完成结果保留 worker 错误,尚未完成则记录超时;等待结束不代表可忽略先前原生错误。
match self.control.wait_shutdown(Duration::from_secs(5)) {
Some(outcome) => {
if let Some(error) = outcome.error {
// 完成通知只证明 worker 已结束清理,仍须保留其错误供 main 的 exit_result 报告。
self.native_error = Some(error);
}
}
None => self.native_error = Some("VM shutdown did not finish within 5 seconds".into()),
}
}
/// 当前不在进入等待前轮询工作;文档通知和原生事件负责唤醒循环,避免空闲忙转。
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {}
/// 应用获得原生活跃上下文时建立窗口,并仅在需要时请求初始页面。
// winit 活跃通知或主实例创建子资源时调用;只对主窗口首次空会话发一次 New tab 初始化。
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.resource == crate::window::MAIN_WINDOW
&& !self.startup_page_requested
&& self
.namespace
.read()
.is_ok_and(|n| n.runtime_session == 0 && n.document.frame.tabs.is_empty())
{
// 先标记再发送后台初始化,重入 resumed 不会重复请求;本字段不表示起始页面已执行或发布成功。
self.startup_page_requested = true;
self.developer_tools.initialize_tools("New tab");
}
// 主窗口创建失败退出事件循环,子窗口失败由主 reconcile 收集错误展示,不直接结束全部窗口。
if let Err(error) = self.create_native(event_loop) {
self.native_error = Some(format!(
"Window {} initialization failed: {error:#}",
self.resource
));
if self.resource == crate::window::MAIN_WINDOW {
event_loop.exit();
}
}
}
/// 处理 worker 发回的文档更新、故障和退出完成通知,不在 GUI 线程执行源回调。
// EventLoopProxy 通知入口,所有状态更改回到原生事件线程;来自工具线程的消息只带 String 和纯数据。
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: AppEvent) {
// 运行时开始退出后丢弃文档/工具错误等普通消息,只让 ShutdownComplete 结束原生循环。
if self.control.is_exiting() && !matches!(&event, AppEvent::ShutdownComplete { .. }) {
return;
}
match event {
AppEvent::ShutdownComplete { error } => {
if let Some(error) = error {
// 与 exiting 的最终等待复用同一错误结果;接收完成事件后才结束 winit 循环,不在此重新清理 VM。
self.native_error = Some(error);
}
event_loop.exit();
}
// 展示故障并撤掉普通点击/键盘激活捕获,不在此自行重建 VM 或把错误当业务页面。
AppEvent::RuntimeFailed { message } => {
self.developer_tools.input_error = message;
self.developer_tools.open = true;
self.guest_pressed = None;
self.guest_key = None;
self.request_redraw();
}
AppEvent::DocumentUpdated => self.document_updated(event_loop),
AppEvent::ConsoleFailed { message, source } => {
self.developer_tools.input_error = format!("Request failed: {message}");
// 仅编辑框仍为空时恢复失败发送的 source,不覆盖用户在等待网络期间新写的草稿。
if self.developer_tools.input.is_empty() {
self.developer_tools.input = source;
self.developer_tools.cursor = self.developer_tools.input.len();
self.developer_tools.reveal_cursor();
}
self.request_redraw();
}
}
}
/// 处理原生尺寸、输入、焦点和关闭事件;先判断宿主控件,再转发页面事件。
// winit OS 事件总入口,先按 WindowId 分流,再在当前窗口处理宿主/页面输入及表面重绘。
fn window_event(
&mut self,
_event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
// 关闭清理中不再处理尺寸或输入,防止源卸载期间又产生新的交互工作。
if self.control.is_exiting() {
return;
}
// 克隆 Arc 结束对 self.window 的借用,下面调用可变 self 方法时原生句柄仍被本事件持有。
let Some(window) = self.window.as_ref().cloned() else {
return;
};
// 主处理器先按 OS WindowId 转发子窗口事件;转发后汇总视口,供同一运行时布局所有窗口。
if window.id() != window_id {
if let Some(child) = self.children.values_mut().find(|child| {
child
.window
.as_ref()
.is_some_and(|window| window.id() == window_id)
}) {
child.window_event(_event_loop, window_id, event);
self.publish_viewport();
}
return;
}
match event {
// 主窗口关闭触发应用退出;子窗口关闭先发 window-close,由源程序生命周期逻辑决定资源清理。
WindowEvent::CloseRequested => {
if self.resource == crate::window::MAIN_WINDOW {
self.request_exit();
} else {
// 副窗口关闭携带当前本地发布投影的身份,worker 会对照主 Namespace 的权威资源再决定源关闭或故障退役。
let input = self
.namespace
.read()
.ok()
.map(|namespace| crate::events::Input {
window: self.resource,
session: namespace.document.frame.session,
layout_version: namespace.document.frame.layout_version,
mount: 0,
kind: "window-close".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
});
if let Some(input) = input {
// 只是尝试入队,不立即删除 OS 窗口;实际资源消失由后续文档协调或 Close 命令反映。
self.send_guest(input);
}
}
}
// 重新读取真实窗口尺寸与 DPI,发布逻辑页面约束,再等待 RedrawRequested 生成像素。
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
self.publish_viewport();
window.request_redraw();
}
WindowEvent::CursorMoved {
position: PhysicalPosition { x, y },
..
} => {
let scale = window.scale_factor() as f32;
// OS 指针是物理像素,先除 DPI 得逻辑坐标;后续侧栏命中和页面世界坐标转换都从该坐标出发。
let next = Point::new(x as f32 / scale, y as f32 / scale);
// 增量与 pointer 都是原生逻辑单位,页面相机路径会再按 Surface zoom 还原,不在这里混入世界坐标。
let delta = Point::new(next.x - self.pointer.x, next.y - self.pointer.y);
self.pointer = next;
self.sidebar
.interaction
.pointer_moved(next, &self.sidebar.targets);
if !self.panning
&& let Some(press) = &self.guest_pressed
&& let Some(point) = self.guest_point()
&& let Some(start) = press.point
{
// 只有按下目标仍是可控制 3D 视口才将超过阈值的移动升级为 orbit,普通按钮拖动不触发相机。
let is_3d = self.namespace.read().is_ok_and(|current| {
current.document.frame.mounts.iter().any(|mount| {
mount.id == press.mount
&& mount.scene3d_viewport
&& mount.camera_controls
})
});
// 3D 相机拖动超过世界坐标下的 3 单位阈值后转为 orbit,并清除 click 捕获,避免释放时误激活。
if is_3d && (point.x - start[0]).hypot(point.y - start[1]) > 3. {
self.orbiting = true;
// 超阈值后才开始相机手势,begin_pan 按当前指针位置选相机;普通点击捕获随后丢弃。
self.panning = self.begin_pan();
self.guest_pressed = None;
}
}
// 捕获相机拖动优先消费 CursorMoved,当前事件不再推进页面 pointermove 或开发工具拖选。
if self.panning {
self.page_view("page-pan", [-delta.x, -delta.y]);
window.request_redraw();
return;
}
// 未进入相机手势才更新业务指针订阅,然后让工具更新自身悬停/拖动;二者都不直接执行 VM 回调。
self.move_pointer();
self.developer_tools.move_pointer(
next,
delta,
&self.layout.developer_tools,
window.inner_size().width as f32 / scale,
);
window.set_cursor(
if self.guest_hit().is_some()
|| self
.sidebar
.interaction
.hit(&self.sidebar.targets)
.is_some()
{
winit::window::CursorIcon::Pointer
} else {
self.developer_tools
.cursor_kind(&self.layout.developer_tools)
},
);
window.request_redraw();
}
// 离开窗口主动取消指针捕获并停止平移,避免缺失鼠标释放后一直处于拖动状态。
WindowEvent::CursorLeft { .. } => {
self.cancel_pointer();
self.guest_pressed = None;
self.panning = false;
self.sidebar.interaction.pointer_left();
self.developer_tools.pointer_left();
window.set_cursor(winit::window::CursorIcon::Default);
window.request_redraw();
}
WindowEvent::MouseInput {
state,
button: MouseButton::Middle,
..
} => {
// 中键只进入平移不进入 orbit,释放时表达式令 panning=false,并撤掉普通 click 捕获。
self.orbiting = false;
self.panning = state == ElementState::Pressed && self.begin_pan();
self.guest_pressed = None;
window.request_redraw();
}
WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} => {
if state == ElementState::Pressed {
// 连续 pointer 订阅优先于默认 click/侧栏分流,按下捕获接收者并发 pointerdown,随后提前返回。
if let Some(input) = self.pointer_target() {
self.pointer_capture = Some(input.clone());
self.emit_pointer(input, "pointerdown");
return;
}
// 左键抬起消费原捕获并发 pointerup,这条路径不再额外合成默认 click。
} else if let Some(input) = self.pointer_capture.take() {
self.emit_pointer(input, "pointerup");
return;
}
if state == ElementState::Pressed {
// 宿主占用区域先捕获侧栏控件,只有顶部空白安全区域允许开始系统窗口拖动。
if self.sidebar.covers(self.pointer) {
self.sidebar_keyboard = true;
self.sidebar.interaction.press(&self.sidebar.targets);
self.guest_pressed = None;
if self.sidebar.open
&& self.pointer.y < crate::sidebar::HEADER
&& self.pointer.x >= crate::sidebar::CONTROLS
&& self
.sidebar
.interaction
.hit(&self.sidebar.targets)
.is_none()
{
#[cfg(target_os = "macos")]
if crate::native_chrome::titlebar_double_click(&window) {
// macOS 原生双击标题栏已消费本次动作,不再叠加一次系统窗口拖动。
return;
}
let _ = window.drag_window();
}
// Space+左键在页面区域尝试相机平移,清 click 捕获,避免拖动结束再激活组件。
} else if self.space_down && self.guest_point().is_some() {
self.orbiting = false;
self.panning = self.begin_pan();
self.guest_pressed = None;
} else {
self.sidebar_keyboard = false;
self.sidebar.interaction.blur();
// 普通按下决定业务键盘归属;页面得到输入时 blur 工具,命中工具时 guest_hit 自然返回 None。
self.guest_keyboard = !self.guest_covered(self.pointer);
if self.guest_keyboard {
self.developer_tools.blur();
}
self.guest_pressed = self.guest_hit();
self.developer_tools.press(
&self.layout.developer_tools,
self.pointer,
&self.activity,
);
}
} else {
// 拖动结束仅结束手势,不把同一次释放再当成点击;普通点击需保持按下与释放的帧身份一致。
if self.panning {
self.panning = false;
self.guest_pressed = None;
window.request_redraw();
return;
}
// 先尝试宿主释放激活,未激活才比较页面按下/释放令牌;不能让一个抬起同时切页又发业务 click。
if let Some(action) = self.sidebar.interaction.release(&self.sidebar.targets) {
self.sidebar_action(action);
} else if let Some(pressed) = self.guest_pressed.take()
&& let Some(mut released) = self.guest_hit()
&& released.session == pressed.session
&& released.layout_version == pressed.layout_version
{
// 保留起点用于运行时判断点击/拖动语义;目标来自释放位置,具体可接受性仍由 worker 校验。
released.press_point = pressed.point;
self.send_guest(released);
}
self.developer_tools.release(
&self.layout.developer_tools,
self.pointer,
&self.activity,
);
}
window.request_redraw();
}
// 捏合增量转成指数缩放倍率交 page_view,捕获/覆盖和相机类型仍由统一路径判定。
WindowEvent::PinchGesture { delta, .. } => {
self.page_view("page-zoom", [(delta as f32).exp(), 0.0]);
window.request_redraw();
}
WindowEvent::MouseWheel { delta, .. } => {
// 行滚动按每行 38 逻辑像素换算,像素滚动除去 DPI;Shift 在几乎没有横向分量时将纵向量转横向。
let (mut dx, mut dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => (x * 38.0, y * 38.0),
MouseScrollDelta::PixelDelta(p) => (
p.x as f32 / window.scale_factor() as f32,
p.y as f32 / window.scale_factor() as f32,
),
};
// Shift 将几乎纯纵向滚轮改成横向滚动,不覆盖用户已有明显横向分量。
if self.modifiers.shift_key() && dx.abs() < 0.1 {
dx = dy;
dy = 0.0;
}
// 侧栏范围内滚轮不穿透页面;只有展开的页面列表区改变 scroll,上限由 sidebar::draw 钳制。
if self.sidebar.covers(self.pointer) {
if self.sidebar.open && self.pointer.y >= 333.0 {
self.sidebar.scroll -= dy;
}
window.request_redraw();
return;
}
let point = self.guest_point();
// 整页 Canvas 或命中可控制局部相机都允许命令+滚轮缩放;其他页面仍先尝试内容滚动。
let canvas = self.namespace.read().is_ok_and(|n| {
n.document.frame.surface.mode == crate::page::Mode::Canvas
|| point.is_some_and(|point| n.document.frame.camera_at(point).is_some())
});
if canvas && (self.modifiers.super_key() || self.modifiers.control_key()) {
// 命令+滚轮优先转换为缩放倍率,钳到事件协议范围;返回后不会再把同一次滚轮当内容滚动。
self.page_view("page-zoom", [(dy * 0.005).exp().clamp(0.1, 10.0), 0.0]);
window.request_redraw();
return;
}
// 记录当前位置是否为可控制 3D 相机,后面只有滚动容器未消费时才用它做无修饰缩放。
let scene3d = self.namespace.read().is_ok_and(|current| {
point
.and_then(|point| current.document.frame.camera_at(point))
.is_some_and(|mount| mount.scene3d_viewport)
});
// 此处重新取得页面点用于滚动目标,后面 Input 带该点与反缩放后的增量,由 worker 再做几何/能力复核。
let point = self.guest_point();
let input = point.and_then(|point| {
self.namespace.read().ok().and_then(|namespace| {
let frame = &namespace.document.frame;
if frame.session == 0 || frame.fault.is_some() {
// 无活会话或故障帧只保留显示,不能据其旧 ScrollBlock 几何继续产生可执行滚动。
return None;
}
// 滚动方向取反并撤销整页 zoom,wheel_target 只选至少一轴能移动的最近祖先容器。
let delta = [-dx / frame.surface.zoom, -dy / frame.surface.zoom];
let mount = crate::scroll::wheel_target(frame, point, delta)?;
Some(crate::events::Input {
window: self.resource,
session: frame.session,
layout_version: frame.layout_version,
mount,
kind: "scroll".into(),
point: Some([point.x, point.y]),
press_point: None,
tab_drop: None,
screen_position: None,
delta: Some(delta),
})
})
});
// 滚动目标优先;无目标才尝试 3D 缩放,再按页面模式平移,最后工具区域自行处理滚轮。
if let Some(input) = input {
self.send_guest(input);
} else if scene3d {
self.page_view("page-zoom", [(dy * 0.005).exp().clamp(0.1, 10.), 0.]);
} else {
// Horizontal 页面在几乎无横向输入时用纵向滚轮驱动横向平移,其他情况保持两轴原方向。
let horizontal = self.namespace.read().is_ok_and(|n| {
n.document.frame.surface.mode == crate::page::Mode::Horizontal
});
self.page_view(
"page-pan",
if horizontal && dx.abs() < 0.1 {
// 仅几乎没有横向输入时把纵向量映到页面横轴,已有明确横向量则走下面保留双轴的分支。
[-dy, 0.0]
} else {
[-dx, -dy]
},
);
}
self.developer_tools
.wheel(&self.layout.developer_tools, self.pointer, dx, dy);
window.request_redraw();
}
// 预编辑只更新工具组合文本并重绘,不把未确认候选放入正式 input 或发给业务对象。
WindowEvent::Ime(Ime::Preedit(value, _)) => {
self.developer_tools.preedit = value;
window.request_redraw();
}
// 确认文本先清组合显示,只有工具打开时才走焦点对应 insert;这不是向 VM 页面发送通用文字输入。
WindowEvent::Ime(Ime::Commit(value)) => {
self.developer_tools.preedit.clear();
if self.developer_tools.open {
// 已确认的组合文本进入工具当前焦点编辑器,既不作为原生 KeyEvent 重放,也不自动提交到 Console 执行。
self.developer_tools.insert(&value);
}
window.request_redraw();
}
// 失焦可能导致收不到对应释放事件,因此清空按键/鼠标捕获、平移状态及修饰键,避免恢复焦点后卡住。
WindowEvent::Focused(false) => {
self.cancel_pointer();
self.guest_pressed = None;
self.guest_key = None;
self.developer_tools.blur();
self.sidebar.interaction.blur();
self.panning = false;
self.space_down = false;
self.modifiers = ModifiersState::default();
window.request_redraw();
}
WindowEvent::ModifiersChanged(modifiers) => self.modifiers = modifiers.state(),
WindowEvent::KeyboardInput { event, .. } => self.handle_keyboard(&event),
WindowEvent::RedrawRequested => self.redraw(),
_ => {}
}
}
}