docs: explain native input capture and window event lifecycle

This commit is contained in:
2026-11-12 19:25:56 +08:00
parent b95912d840
commit 0971d08e1b
+97 -1
View File
@@ -582,10 +582,12 @@ impl TesseraApp {
}
/// 判断指针是否被侧栏或管理面板覆盖,阻止一次点击同时落入页面。
// 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(
@@ -598,12 +600,15 @@ impl TesseraApp {
}
/// 排除宿主管理区域后,把原生指针转换到页面使用的坐标系。
// 普通命中、指针派发和手势入口共用;返回页面 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
@@ -616,6 +621,7 @@ impl TesseraApp {
}
/// 从页面坐标与当前帧命中结果构造带版本的点击输入。
// 鼠标按下/释放、指针订阅入口和光标显示共同读取当前帧的粗命中,不在 GUI 内调用源 hitTest。
fn guest_hit(&self) -> Option<crate::events::Input> {
let point = self.guest_point()?;
let current = self.namespace.read().ok()?;
@@ -624,6 +630,7 @@ impl TesseraApp {
return None;
}
// 命中的是当前发布帧,事件同时携带 session/layout_version;worker 可拒绝排队期间已过期的输入。
// Frame 先通过空间候选与原生 contains 选择交互出现,真正源命中及按下/释放比较留给 worker。
let mount = frame.hit_test(point)?;
Some(crate::events::Input {
window: self.resource,
@@ -641,26 +648,32 @@ impl TesseraApp {
/// 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");
@@ -669,11 +682,14 @@ impl TesseraApp {
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))
@@ -681,6 +697,7 @@ impl TesseraApp {
{
self.emit_pointer(old, "pointerleave");
}
// 新/持续悬停目标收到 pointermove;本函数没有单独构造 pointerenter。
if let Some(input) = next.clone() {
self.emit_pointer(input, "pointermove");
}
@@ -688,13 +705,16 @@ impl TesseraApp {
}
/// 将标签快捷键转换为当前窗口/帧中的明确页面动作。
// 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() {
@@ -715,6 +735,7 @@ impl TesseraApp {
screen_position: None,
})
});
// 切页/关页前丢弃旧鼠标点击和键盘 click 捕获,避免释放事件落入即将变化的页面。
if let Some(input) = input {
self.guest_pressed = None;
self.guest_key = None;
@@ -726,11 +747,13 @@ impl TesseraApp {
}
/// 处理侧栏展开、工具入口和页面管理,尺寸变化还需重新发布可用视口。
// 侧栏鼠标、键盘以及命令+B 调用,UI 侧只调整宿主开合,业务页面操作仍发带版本事件。
fn sidebar_action(&mut self, action: crate::sidebar::Action) {
use crate::sidebar::Action;
match action {
Action::Toggle => {
self.sidebar.open = !self.sidebar.open;
// 折叠/展开会改变目标集合,立即取消旧捕获并更新键盘归属,视口重新发布触发布局刷新。
self.sidebar.interaction.cancel();
self.sidebar_keyboard = self.sidebar.open;
self.guest_pressed = None;
@@ -738,6 +761,7 @@ impl TesseraApp {
}
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
@@ -760,7 +784,7 @@ impl TesseraApp {
tab_drop: None,
screen_position: None,
});
// 滚轮优先交给可滚动挂载;未命中则尝试 3D 缩放或整页平移,最后更新开发工具自身滚动。
// 读锁成功才构造当前窗口页面事件,实际标签身份和会话有效性仍由 worker 校验。
if let Some(input) = input {
self.send_guest(input);
}
@@ -770,7 +794,9 @@ impl TesseraApp {
}
/// 在手势开始时捕获具体相机视口 ID,后续拖动保持同一目标。
// 中键、Space+左键及 3D 超阈值拖动调用,返回是否可开始手势,同时保存具体视口或整页选择。
fn begin_pan(&mut self) -> bool {
// 先清上次捕获,None 在成功返回时表示整页相机,不等于没有手势;是否激活另由 panning 保存。
self.pan_viewport = None;
let Some(point) = self.guest_point() else {
return false;
@@ -779,18 +805,22 @@ impl TesseraApp {
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;
}
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
@@ -802,6 +832,7 @@ impl TesseraApp {
{
return None;
}
// 先撤销整页 Surface,局部视口收到世界点后再在运行时处理自身相机和面板映射。
let world = frame.surface.to_world(point);
// 拖动沿用捕获 ID,滚轮/缩放则按当前点命中;被捕获的视口若已消失,取消此次输入而不切换目标。
let viewport = if self.panning {
@@ -815,10 +846,12 @@ impl TesseraApp {
} 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,
@@ -841,6 +874,7 @@ impl TesseraApp {
.into(),
point: Some([world.x, world.y]),
press_point: None,
// 局部平移量先除页面 zoom;缩放的第一分量是倍率不除 zoom,第二分量仍按现有转换处理。
delta: Some([
delta[0]
/ if kind == "page-pan" {
@@ -873,6 +907,7 @@ impl TesseraApp {
screen_position: None,
})
});
// 产生相机操作即撤销同次普通 click 捕获,避免拖动或缩放后释放又触发业务点击。
if let Some(input) = input {
self.guest_pressed = None;
self.send_guest(input);
@@ -880,6 +915,7 @@ impl TesseraApp {
}
/// 把已经构造的页面输入交给有界客户端,保持 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();
@@ -888,10 +924,13 @@ impl TesseraApp {
}
/// 把页面拥有焦点时的键盘动作转换为托管事件,保留按下/释放配对。
// 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| {
@@ -905,9 +944,11 @@ impl TesseraApp {
.iter()
.filter(|mount| mount.interactive)
.collect();
// 没有可交互出现就不构造 focus 事件,也不进入后面的取模索引。
if targets.is_empty() {
return None;
}
// 当前焦点令牌通过出现 ID 在本帧候选中定位;不在列表时按首次进入处理。
let current = self
.guest_focus
.as_ref()
@@ -932,6 +973,7 @@ impl TesseraApp {
screen_position: None,
})
});
// 发焦点请求前清旧键盘按下捕获,本地先保存新令牌;worker 仍检查实际合法性,发送失败会显示错误。
if let Some(next) = next {
self.guest_key = None;
self.guest_focus = Some(next.clone());
@@ -942,6 +984,7 @@ impl TesseraApp {
}
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 {
@@ -960,11 +1003,14 @@ impl TesseraApp {
}
/// 按平台窗口属性创建原生窗口、软件表面和输入法支持,再公布实际逻辑视口。
// 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()
@@ -982,6 +1028,7 @@ impl TesseraApp {
crate::window::MAX_WINDOW_DIMENSION,
));
#[cfg(target_os = "macos")]
// macOS 用全尺寸透明标题栏配合自绘侧栏,真实交通灯按钮仍由 native_chrome 对齐。
let attributes = {
use winit::platform::macos::WindowAttributesExtMacOS;
attributes
@@ -997,6 +1044,7 @@ impl TesseraApp {
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);
@@ -1009,7 +1057,9 @@ impl TesseraApp {
}
/// 停止新的交互并请求运行时清理;原生事件循环等待完成通知后退出。
// 主窗口 CloseRequested 和关闭命令调用;正常路径等待 worker ShutdownComplete,再让事件循环退出。
fn request_exit(&mut self) {
// 退出请求成功发送后置位,重复关闭不再创建第二轮清理;发送失败分支自行投递完成错误通知。
if self.exit_requested {
return;
}
@@ -1027,6 +1077,7 @@ impl TesseraApp {
}
/// 把原生应用记录的最终错误反馈给 main 的退出结果。
// main 在 run_app 返回后读取最终原生/关闭错误,不能仅凭 OS 循环结束判断应用正常退出。
pub fn exit_result(&self) -> anyhow::Result<()> {
if let Some(error) = &self.native_error {
anyhow::bail!("{error}");
@@ -1035,11 +1086,13 @@ impl TesseraApp {
}
/// 包装一次实际绘制,统一记录故障并避免对同一错误反复发送通知。
// 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() {
@@ -1057,17 +1110,21 @@ impl TesseraApp {
}
/// 读取完整发布文档,生成整窗显示列表,写入原生像素表面并呈现;错误交由外层记录。
// 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 {
@@ -1088,6 +1145,7 @@ impl TesseraApp {
&mut self.sidebar,
);
drop(namespace);
// 保存本次逻辑命中布局给后续输入,像素绘制使用 scene.display_list 的设备坐标。
self.layout = scene.layout;
window.set_cursor(if guest_cursor {
winit::window::CursorIcon::Pointer
@@ -1095,10 +1153,12 @@ impl TesseraApp {
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);
@@ -1111,6 +1171,7 @@ impl TesseraApp {
surface
.resize(width, height)
.map_err(|error| anyhow::anyhow!("{error}"))?;
// 取得本次目标缓冲后顺序执行整窗列表,渲染报错就不调用下面 present,不将半幅写入当作成功帧。
let mut buffer = surface
.buffer_mut()
.map_err(|error| anyhow::anyhow!("{error}"))?;
@@ -1125,6 +1186,7 @@ impl TesseraApp {
impl ApplicationHandler<AppEvent> for TesseraApp {
/// 处理 OS 直接结束事件循环的收尾,尝试在明确时限内等待运行时清理。
// winit 最终退出回调,无法再依靠 user_event 等通知;此处只做有时限的运行时关闭等待。
fn exiting(&mut self, _: &ActiveEventLoop) {
// OS 终止可能跳过 CloseRequested;此时循环已在退出,无法再等事件通知。
// 最多阻塞 5 秒等待运行时完成清理,超时保留退出错误而不无限等待。
@@ -1132,6 +1194,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
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 {
@@ -1146,6 +1209,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
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
@@ -1154,10 +1218,12 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
.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:#}",
@@ -1170,7 +1236,9 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
}
/// 处理 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;
}
@@ -1181,6 +1249,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
}
event_loop.exit();
}
// 展示故障并撤掉普通点击/键盘激活捕获,不在此自行重建 VM 或把错误当业务页面。
AppEvent::RuntimeFailed { message } => {
self.developer_tools.input_error = message;
self.developer_tools.open = true;
@@ -1191,6 +1260,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
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();
@@ -1202,15 +1272,18 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
}
/// 处理原生尺寸、输入、焦点和关闭事件;先判断宿主控件,再转发页面事件。
// 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;
};
@@ -1255,6 +1328,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
}
}
}
// 重新读取真实窗口尺寸与 DPI,发布逻辑页面约束,再等待 RedrawRequested 生成像素。
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
self.publish_viewport();
window.request_redraw();
@@ -1266,6 +1340,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
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
@@ -1276,6 +1351,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
&& 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
@@ -1290,11 +1366,13 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
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,
@@ -1318,6 +1396,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
);
window.request_redraw();
}
// 离开窗口主动取消指针捕获并停止平移,避免缺失鼠标释放后一直处于拖动状态。
WindowEvent::CursorLeft { .. } => {
self.cancel_pointer();
self.guest_pressed = None;
@@ -1332,6 +1411,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
button: MouseButton::Middle,
..
} => {
// 中键只进入平移不进入 orbit,释放时表达式令 panning=false,并撤掉普通 click 捕获。
self.orbiting = false;
self.panning = state == ElementState::Pressed && self.begin_pan();
self.guest_pressed = None;
@@ -1343,16 +1423,19 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
..
} => {
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);
@@ -1372,6 +1455,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
}
let _ = window.drag_window();
}
// Space+左键在页面区域尝试相机平移,清 click 捕获,避免拖动结束再激活组件。
} else if self.space_down && self.guest_point().is_some() {
self.orbiting = false;
self.panning = self.begin_pan();
@@ -1379,6 +1463,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
} 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();
@@ -1398,6 +1483,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
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()
@@ -1417,6 +1503,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
}
window.request_redraw();
}
// 捏合增量转成指数缩放倍率交 page_view,捕获/覆盖和相机类型仍由统一路径判定。
WindowEvent::PinchGesture { delta, .. } => {
self.page_view("page-zoom", [(delta as f32).exp(), 0.0]);
window.request_redraw();
@@ -1430,10 +1517,12 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
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;
@@ -1442,6 +1531,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
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())
@@ -1451,6 +1541,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
window.request_redraw();
return;
}
// 记录当前位置是否为可控制 3D 相机,后面只有滚动容器未消费时才用它做无修饰缩放。
let scene3d = self.namespace.read().is_ok_and(|current| {
point
.and_then(|point| current.document.frame.camera_at(point))
@@ -1463,6 +1554,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
if frame.session == 0 || frame.fault.is_some() {
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 {
@@ -1479,11 +1571,13 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
})
})
});
// 滚动目标优先;无目标才尝试 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
});
@@ -1500,10 +1594,12 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
.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 {