Files
endorphin-tessera-gui/src/runtime/events.rs
T

560 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。
//! 连续相机和滚动输入出队时可更新帧版本,但不改变捕获目标和所属 VM;离散点击仍保留严格的过期帧语义。
//! 生产端只合并相同目标的相邻绝对指针采样;有序动作是边界。relay 直接进入串行 worker,后台等待确认。
//!
//! shell::app 建立 Client 并发送捕获到的 Input;Client 后台 relay 经 api::RuntimeControl::dispatch_native 交给 worker。
//! HTTP 路由也反序列化同一 Input 并调用 validate,但不会走原生 relay 的连续输入版本刷新。
//! 这里只合并同目标相邻 pointermove 的绝对采样;滚动/相机的增量逐条保留,不能用最后一个覆盖累计移动。
use serde::{Deserialize, Serialize};
use std::{
collections::VecDeque,
sync::{Arc, Condvar, Mutex, RwLock},
thread,
};
use winit::event_loop::EventLoopProxy;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
/// 拖放目标窗口、目标布局版本和插入位置,源标签身份由外层 Input 保存。
pub struct TabDrop {
// 目标原生资源 ID,不是 GUI 标签索引或 Page 托管句柄。
pub window: u64,
// 事件所依据的已发布布局版本,由执行端校验过期帧;原生连续手势有单独刷新逻辑。
pub layout_version: u64,
// 移除前标签序列中的插入缝位置;同窗口向后移动时 dispatch_tab 会做一次下标修正。
pub index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
/// 原生或 HTTP 输入的纯数据报文;身份与坐标必须同时通过 worker 和能力校验。
pub struct Input {
#[serde(default = "default_window")]
// 省略仅为兼容旧 HTTP 报文,显式 0 永远无效;原生客户端始终记录真实捕获窗口。
pub window: u64,
// 产生事件的运行时会话;重载后即使窗口或 Mount 数值相同也不能沿用旧事件。
pub session: u64,
// 产生输入时所见帧的几何版本;同会话内也不能把旧坐标直接用到任意新几何。
pub layout_version: u64,
// 捕获的具体出现身份;页面级相机事件使用零,独立视口相机事件要求非零。
pub mount: u64,
// 分派标签:click/scroll/相机/标签/工具等语义由 validate 与 UiBridge::dispatch 分工检查。
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 事件位置或缩放锚点;click 与 press_point 配对,具体坐标语义由事件 kind 决定。
pub point: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 高层 click 的按下点,与 point 的释放点一起让 source_hit 确认同一个出现被点击。
pub press_point: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 平移/滚动增量,或缩放的 [倍率,0];不能无差别接受任意事件携带该字段。
pub delta: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 仅 tab-drop 可携带,源端身份仍由外层 window/layout_version/mount 指定。
pub tab_drop: Option<TabDrop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 拆标签的新窗口位置是屏幕坐标,与页面局部 point 分开;允许多屏环境负坐标。
pub screen_position: Option<[i32; 2]>,
}
/// serde 在旧报文缺 window 时调用,返回主资源 ID;不会把显式零值改写成合法窗口。
fn default_window() -> u64 {
crate::window::MAIN_WINDOW
}
impl Input {
/// API 路由和 UiBridge 分派前调用,验证报文结构;不读取 VM/发布文档,不能证明 session/Mount 仍有效。
pub fn validate(&self) -> Result<(), &'static str> {
if self.window == 0 {
// 资源身份零保留为非法值,避免丢失 window 的错误报文误投当前窗口。
return Err("window identity must be nonzero");
}
// 标签跨窗口放置仅接受目标元信息;禁止同时夹带指针/滚动/屏幕位置,避免混用两套操作。
if self.kind == "tab-drop" {
// 缺目标就没有可解释的插入窗口/位置,不从 point 或其他字段推断落点。
let Some(target) = &self.tab_drop else {
return Err("tab-drop requires target metadata");
};
if target.window == 0
|| target.index > crate::ui_bridge::MAX_PAGES
|| self.point.is_some()
|| self.press_point.is_some()
|| self.delta.is_some()
|| self.screen_position.is_some()
{
// 拖放用纯目标元数据,不接受与滚动/点击/拆窗定位混合的多义信封。
return Err("invalid tab drop metadata");
}
return Ok(());
}
// 非 tab-drop 不得携带目标窗口信息,即使其他字段恰好合法也拒绝。
if self.tab_drop.is_some() {
// 非拖放报文即使 kind 本身合法,也不能夹带被另一层意外消费的重排指令。
return Err("tab_drop is only accepted for tab-drop");
}
// 屏幕位置只服务 tab-detach 的新窗口定位,允许负数以支持左侧/上方显示器,但限制绝对范围。
if let Some(position) = self.screen_position
&& (self.kind != "tab-detach"
|| !position
.iter()
.all(|value| (-1_000_000..=1_000_000).contains(value)))
{
return Err("screen_position is only accepted for tab-detach with bounded coordinates");
}
if matches!(
self.kind.as_str(),
"page-pan"
| "page-zoom"
| "viewport-pan"
| "viewport-zoom"
| "scene-orbit"
| "scene-pan"
| "scene-zoom"
) {
// 独立视口输入可包含负的局部位置(例如捕获拖动移出视口);页面级锚点要求非负。
let viewport = self.kind.starts_with("viewport-") || self.kind.starts_with("scene-");
// 所有相机操作必须带有限、有界的增量;point 在非缩放操作可省略。
let valid_delta = self.delta.is_some_and(|delta| {
// 两个增量分量都要有限,不能只验证 zoom 会使用的第一分量而遗漏非法第二分量。
delta
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
});
// 非缩放手势可无锚点;存在时仍限制坐标,局部视口可负以支持捕获拖出边界。
let valid_point = self.point.is_none_or(|point| {
point
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0 && (viewport || *v >= 0.0))
});
if !valid_delta
|| !valid_point
|| (if viewport {
self.mount == 0
} else {
self.mount != 0
})
|| self.press_point.is_some()
|| self.screen_position.is_some()
{
// 页面相机 mount 必为零,局部视口必非零,防止把同一增量套到两种目标空间。
return Err("invalid page camera input");
}
// 缩放必须给锚点并使用正倍率 0.1..=10;第二分量必须为零,不把缩放与二维平移混合编码。
if self.kind.ends_with("-zoom")
&& (self.point.is_none()
|| self
.delta
.is_none_or(|delta| !(0.1..=10.0).contains(&delta[0]) || delta[1] != 0.0))
{
// 这里仅检查倍率/锚点格式;锚点是否仍在实际视口内由 page_input 结合几何检查。
return Err("page-zoom requires an anchor and positive scale factor");
}
return Ok(());
}
// 滚动需要当前点与二维有限增量,不接受按下点;Mount 能力与身份由执行端进一步核对。
if self.kind == "scroll" {
return match (self.point, self.press_point, self.delta) {
(Some(point), None, Some(delta))
if point
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
&& delta
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0) =>
{
// 滚动只接点+增量,具体可消费方向、裁剪和嵌套链仍在桥接/滚动模块决定。
Ok(())
}
_ => Err("scroll requires point and finite delta, with no press_point"),
};
}
if matches!(
self.kind.as_str(),
"pointermove" | "pointerdown" | "pointerup" | "pointerleave" | "pointercancel"
) {
// 原始 pointer 报文要求一个明确目标和绝对位置,不允许夹带 click 的成对按下点或增量。
return if self.mount != 0
&& self.press_point.is_none()
&& self.delta.is_none()
&& self.screen_position.is_none()
&& self
.point
.is_some_and(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.))
{
// 格式合法后还需检查挂载和 pointer_events;此处不把任意 Mount 变成可交互目标。
Ok(())
} else {
// 错误字段或越界坐标统一拒绝,不替发送方删字段修复成另一个事件。
Err("pointer event requires finite point and target")
};
}
if self.delta.is_some() {
// 到这里相机与滚动路径都已返回,其余离散操作不得再消费 delta。
return Err("delta is only accepted for scroll input");
}
// 无指针坐标的离散事件可通过此结构校验;若有指针信息,必须是 click 的按下/释放成对坐标。
// 这里不做完整 kind 白名单授权,具体操作仍由后续分发层检查。
match (self.point, self.press_point) {
// 无坐标的 focus/标签等离散动作交后续 kind 白名单与对象能力检查。
(None, None) => Ok(()),
(Some(point), Some(press))
if self.kind == "click"
&& point
.into_iter()
.chain(press)
.all(|value| value.is_finite() && value.abs() <= 1_000_000.0) =>
{
// click 必须同时带两个有效端点,避免只在释放点命中就触发按下在别处的按钮。
Ok(())
}
_ => Err("pointer click requires finite press/release coordinates"),
}
}
}
/// Client 生产端与 relay 共用的有界状态;只保留尚未提交 worker 的 Input,不包含 VM 对象。
struct Queue {
// 相邻同目标绝对移动可在队尾覆盖;其他动作是序列屏障,不能越过它们合并采样。
items: VecDeque<Input>,
// 统计活 Client,而非 Arc 总强引用;后台 relay 自己持 Arc 也不能阻止最后生产者关闭队列。
senders: usize,
// 最后生产者释放后置位,receive 醒来退出,不再尝试分派已无人负责的旧输入。
closed: bool,
}
/// 队列锁与条件变量的共享入口,Client 只短暂加锁,后台等待不占用 GUI 事件循环。
struct Inbox {
queue: Mutex<Queue>,
ready: Condvar,
}
/// shell::app 持有的原生输入发送句柄;后台 relay 负责串行等待 worker 确认,GUI 不等待 VM 执行。
pub struct Client(Arc<Inbox>);
impl Clone for Client {
/// GUI 多个生产者复制句柄时同步增加逻辑生产者数,保证 Drop 不把仍在用的队列提前关闭。
fn clone(&self) -> Self {
self.0.queue.lock().unwrap().senders += 1;
Self(self.0.clone())
}
}
impl Drop for Client {
/// 原生生产者离开时减少计数;最后一个关闭、清空并唤醒 relay,不因 relay 自己的 Arc 而挂住线程。
fn drop(&mut self) {
if let Ok(mut q) = self.0.queue.lock() {
// 锁内计数/关闭与 send/enqueue 互斥,关闭后不留下新的待发报文。
q.senders -= 1;
if q.senders == 0 {
// 退出丢弃尚未发送输入,不用旧会话的残留交互拖延窗口关闭。
q.closed = true;
q.items.clear();
self.0.ready.notify_all();
}
}
}
}
/// Client::send 在锁内调用,最多保留 64 条,队尾同目标 pointermove 可覆盖成最新绝对采样。
fn enqueue(queue: &mut VecDeque<Input>, input: Input) -> bool {
if input.kind == "pointermove"
&& let Some(last) = queue.back_mut()
&& last.kind == "pointermove"
&& (last.window, last.session, last.mount) == (input.window, input.session, input.mount)
{
// 只覆盖紧邻且同窗/同会话/同 Mount 的移动;按下/抬起或换目标都成为不可跨越的屏障。
*last = input;
return true;
}
if queue.len() >= 64 {
// 容量检查在可合并路径之后,因此单一连续移动不会因同类采样填满队列。
return false;
}
queue.push_back(input);
true
}
impl Inbox {
/// relay 的接收循环调用,条件变量等待新事件或关闭,唤醒后再次检查以处理虚假唤醒。
fn receive(&self) -> Option<Input> {
// 锁损坏视为关闭,不尝试从不可靠队列恢复一条可能重复的输入。
let mut q = self.queue.lock().ok()?;
while q.items.is_empty() && !q.closed {
// wait 原子放锁并等待;GUI 生产者可入队,通知后重新取得同一状态锁。
q = self.ready.wait(q).ok()?;
}
if q.closed { None } else { q.items.pop_front() }
}
}
impl Client {
/// shell::app 初始化时调用,创建有界 Inbox 与单个 relay;RuntimeControl 直达 worker,不绕 HTTP 回环。
pub fn new(
proxy: EventLoopProxy<crate::app::AppEvent>,
namespace: Arc<RwLock<crate::document::NamespaceState>>,
control: crate::api::RuntimeControl,
) -> anyhow::Result<Self> {
// 初始生产者就是返回的 Client;receiver 的 Arc 不计入 senders,避免接收线程自保活。
let inbox = Arc::new(Inbox {
queue: Mutex::new(Queue {
items: VecDeque::new(),
senders: 1,
closed: false,
}),
ready: Condvar::new(),
});
// move 闭包拥有接收 Arc 与发布状态只读入口,线程不取得 VM 所有权。
let receiver = inbox.clone();
thread::Builder::new()
.name("tessera-native-events".into())
.spawn(move || {
while let Some(mut input) = receiver.receive() {
// 每条输入出队时再看最新权威发布帧,连续采样不沿用排队前已经过时的布局版本。
if let Ok(current) = namespace.read() {
if !refresh_continuous_input(&mut input, &current) {
// 目标消失/换会话时丢弃,不能把旧手势重定向到同位置的新出现。
continue;
}
} else {
// 共享发布状态不可读时不分派缺乏校验的输入,等待下一条正常事件。
continue;
}
if let Err(error) = control.dispatch_native(input) {
// worker 回执错误通过原生事件循环呈现诊断,不直接操作窗口或执行源回调。
let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed {
message: error.to_string(),
});
}
}
})?;
Ok(Self(inbox))
}
/// shell::app 捕获输入时调用,非阻塞入队/合并后通知 relay;true 不表示 VM 已执行或帧已发布。
pub fn send(&self, input: Input) -> bool {
// 锁内仅处理纯数据队列,不把 worker 回执等待放在 GUI 路径。
let Ok(mut q) = self.0.queue.lock() else {
return false;
};
if q.closed {
return false;
}
// enqueue 的覆盖合并也算接收成功,relay 会读取最终最新样本。
let queued = enqueue(&mut q.items, input);
if queued {
// 单 relay 只需唤醒一个等待者;关闭路径用 notify_all 保证退出不遗留等待。
self.0.ready.notify_one();
}
queued
}
}
/// Client relay 出队时调用,仅给指定类别的原生输入追随同目标新帧版本;不修改捕获目标和会话身份。
/// HTTP 调用不走这个入口;click/标签等其余动作直接保留旧版本,后面仍会严格拒绝过期帧。
pub(crate) fn refresh_continuous_input(
input: &mut Input,
current: &crate::document::NamespaceState,
) -> bool {
if !matches!(
input.kind.as_str(),
"viewport-pan"
| "viewport-zoom"
| "scene-pan"
| "scene-orbit"
| "scene-zoom"
| "scroll"
| "pointermove"
| "pointerdown"
| "pointerup"
| "pointerleave"
| "pointercancel"
) {
// 未列入可追随类别的输入既不丢弃也不改写,在执行端按原几何版本验证。
return true;
}
// 选取事件原窗口的权威帧;窗口已经消失时丢弃,不能把手势改投主窗口。
let frame = if input.window == crate::window::MAIN_WINDOW {
Some(&current.document.frame)
} else {
current
.document
.windows
.get(&input.window)
.map(|document| &document.frame)
};
let Some(frame) = frame else {
// 原窗口不再有权威帧,不能改选 current/main 去继续应用相机或指针动作。
return false;
};
// 必须仍属同一运行时会话、帧无故障且原捕获 Mount 具备对应能力,才可更新布局版本。
if current.runtime_session != input.session
|| frame.session != input.session
|| frame.fault.is_some()
|| !frame.mounts.iter().any(|mount| {
mount.id == input.mount
&& if input.kind.starts_with("viewport-") {
// 独立二维相机只要求原 Mount 仍为 CanvasViewport。
mount.canvas_viewport
} else if input.kind.starts_with("scene-") {
// 3D 还需相机控制器,禁用控制器后旧手势不能继续改模型。
mount.scene3d_viewport && mount.camera_controls
} else if input.kind.starts_with("pointer") {
// 原始指针必须仍有相应订阅能力,外框存在不足以证明可以继续接收。
mount.pointer_events
} else {
// 剩余唯一可追随类别是 scroll,需保持滚动能力。
mount.scroll.is_some()
}
})
{
// 保留会话/目标边界,任一权威身份或能力不匹配都不能仅更新版本后继续发送。
return false;
}
// 只追随新版本,不改 session/window/mount,不重选目标;每条排队增量仍独立发送。
input.layout_version = frame.layout_version;
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// Rust 测试框架调用:用真实持久 VM 反复分派旧帧 pan,验证版本追随不丢增量也不越过会话/目标边界。
fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() {
// 建立可观测 camera.x 的最小场景,输入目标从实际发布帧选出,不硬编码出现 ID。
let (mut session, document) = crate::endor_host::ReplSession::load(
"view=CanvasViewport(200,100,Scene2D(RectBlock(20,20,0)))\npage=Page()\npage.render(view)",
"app.main", 1, 912,
).unwrap();
// 找真实 CanvasViewport 出现,确保 refresh_continuous_input 的能力判定有实际帧数据。
let mount = document
.frame
.mounts
.iter()
.find(|mount| mount.canvas_viewport)
.unwrap()
.id;
// 捕获初始版本的固定输入,后面每轮都克隆它,模拟输入生产速度超过刷新速度的队列。
let original = Input {
window: 1,
session: 912,
layout_version: document.frame.layout_version,
mount,
kind: "viewport-pan".into(),
point: Some([50., 50.]),
delta: Some([3., 0.]),
press_point: None,
tab_drop: None,
screen_position: None,
};
// current 表示 relay 每次出队时能读到的最新共享文档,不直接借用 VM 当前内部草稿。
let mut current = crate::document::NamespaceState {
runtime_session: 912,
document,
..Default::default()
};
// 反复从旧帧原始手势克隆输入,验证每条增量都被刷新版本并累计,12×3 应得到 36。
for _ in 0..12 {
// 每轮都从最旧版本重建输入,只有 refresh 的行为能让后续 11 条继续有效。
let mut queued = original.clone();
assert!(refresh_continuous_input(&mut queued, &current));
current.document = session.dispatch(&queued).unwrap().document.unwrap();
}
assert_eq!(
session.execute("view.camera.x", "app.main").unwrap().value,
"36"
);
// 离散点击不得自动刷新版本;随后验证会话替换和目标移除都会拒绝连续手势。
// 同一信封改成离散 click,应只通过“不改写”快路,不享受连续手势的版本追随。
let mut click = original.clone();
click.kind = "click".into();
assert!(refresh_continuous_input(&mut click, &current));
assert_eq!(click.layout_version, original.layout_version);
// 模拟整程序替换:数字窗口/Mount 即使相同,旧 runtime_session 仍必须使手势失效。
current.runtime_session = 913;
assert!(!refresh_continuous_input(&mut original.clone(), &current));
current.runtime_session = 912;
// 恢复原会话但删掉目标,证明能力/目标检查不只依赖 session 相等。
current.document.frame.mounts.clear();
assert!(!refresh_continuous_input(&mut original.clone(), &current));
}
#[test]
/// Rust 测试框架调用,逐项破坏合法拖放报文,验证种类与辅助字段绑定、标签上限及多屏坐标范围。
fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() {
// 从完整合法 JSON 起步,后面的每次修改只触发一种目标限制,避免无关字段同时掩盖失败原因。
let mut input:Input=serde_json::from_str(r#"{"window":1,"session":1,"layout_version":2,"mount":3,"kind":"tab-drop","tab_drop":{"window":2,"layout_version":4,"index":1}}"#).unwrap();
assert!(input.validate().is_ok());
// tab_drop 元数据不可由普通 click 夹带,否则某层可能意外消费成跨窗口移动。
input.kind = "click".into();
assert!(input.validate().is_err());
input.kind = "tab-drop".into();
// 数字 65 超过 MAX_PAGES=64 的结构上界,不需要真实 VM 窗口即可拒绝。
input.tab_drop.as_mut().unwrap().index = 65;
assert!(input.validate().is_err());
// 拆窗使用另一种辅助元数据,先去掉 tab_drop,再验证负屏幕坐标合法但极值不合法。
input.kind = "tab-detach".into();
input.tab_drop = None;
input.screen_position = Some([-400, 200]);
assert!(input.validate().is_ok());
input.screen_position = Some([i32::MIN, 0]);
assert!(input.validate().is_err());
}
#[test]
/// Rust 测试框架调用,区分 serde 的缺字段默认与明确传零,保证兼容不掩盖非法资源身份。
fn legacy_event_window_defaults_but_zero_is_never_a_resource() {
// 故意省略 window,检查反序列化确实调用 default_window,而非字段初始零值。
let mut input: Input =
serde_json::from_str(r#"{"session":1,"layout_version":2,"mount":3,"kind":"focus"}"#)
.unwrap();
assert_eq!(input.window, crate::window::MAIN_WINDOW);
assert!(input.validate().is_ok());
// 显式修改为零必须拒绝,不能再次触发 default_window 纠正成主窗口。
input.window = 0;
assert!(input.validate().is_err());
}
}
#[cfg(test)]
mod coalescing_tests {
use super::*;
#[test]
/// Rust 测试框架调用,验证生产端高频绝对移动可合并,动作顺序和跨窗口目标切换仍保持屏障。
fn producer_side_motion_coalescing_keeps_action_and_target_boundaries() {
// 默认主窗口、固定 session/Mount 的原始绝对位置采样,供后续复制成不同动作。
let template: Input = serde_json::from_str(
r#"{"session":1,"layout_version":1,"mount":2,"kind":"pointermove","point":[0,0]}"#,
)
.unwrap();
// 直接测试 enqueue 的纯队列规则,不启动 relay 或 VM,避免异步消费影响可观察顺序。
let mut q = VecDeque::new();
for i in 0..1000 {
// 即使远超容量 64,相邻同目标 pointermove 也应持续覆盖唯一队尾项。
let mut sample = template.clone();
sample.point = Some([i as f32, 0.]);
assert!(enqueue(&mut q, sample));
}
assert_eq!(q.len(), 1);
assert_eq!(q[0].point.unwrap()[0], 999.);
// down 是离散动作屏障,不能被下一次移动覆盖或跨过。
let mut down = template.clone();
down.kind = "pointerdown".into();
enqueue(&mut q, down);
// 新移动只能形成 down 后的一项,不能合并回 down 前的旧移动。
let mut moved = template.clone();
moved.point = Some([1200., 0.]);
enqueue(&mut q, moved);
// up 再次切开动作序列,保证处理器观察先按下、后移动、再抬起。
let mut up = template.clone();
up.kind = "pointerup".into();
enqueue(&mut q, up);
// 相同 session/Mount 但换窗口也必须单独成项,目标比较不能漏掉 window。
let mut other = template;
other.window = 2;
enqueue(&mut q, other);
assert_eq!(
q.iter().map(|i| i.kind.as_str()).collect::<Vec<_>>(),
[
"pointermove",
"pointerdown",
"pointermove",
"pointerup",
"pointermove"
]
);
assert_eq!(q.back().unwrap().window, 2);
}
}