1669 lines
87 KiB
Rust
1669 lines
87 KiB
Rust
//! 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 = ¤t.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 = ¤t.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 = ¤t.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 = ¤t.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(),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|