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

356 lines
13 KiB
Rust

//! 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。
//! 连续相机和滚动输入出队时可更新帧版本,但不改变捕获目标和所属 VM;离散点击仍保留严格的过期帧语义。
//! 队列有界且当前逐项经本地 HTTP 发送;这不意味着输入已经按显示帧合并,也不是无损无限队列。
//!
//! Native input transport. Frame/mount identities and optional logical pointer positions cross to the VM worker.
use serde::{Deserialize, Serialize};
use std::{
io::{Read, Write},
net::TcpStream,
sync::{
Arc, RwLock,
mpsc::{SyncSender, sync_channel},
},
thread,
time::Duration,
};
use winit::event_loop::EventLoopProxy;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
/// 拖放目标窗口、目标布局版本和插入位置,源标签身份由外层 Input 保存。
pub struct TabDrop {
pub window: u64,
pub layout_version: u64,
pub index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
/// 原生或 HTTP 输入的纯数据报文;身份与坐标必须同时通过 worker 和能力校验。
pub struct Input {
#[serde(default = "default_window")]
pub window: u64,
pub session: u64,
pub layout_version: u64,
pub mount: u64,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub press_point: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_drop: Option<TabDrop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_position: Option<[i32; 2]>,
}
/// 返回默认主窗口身份,供省略窗口字段的兼容输入使用。
fn default_window() -> u64 {
crate::window::MAIN_WINDOW
}
impl Input {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
pub fn validate(&self) -> Result<(), &'static str> {
if self.window == 0 {
return Err("window identity must be nonzero");
}
if self.kind == "tab-drop" {
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(());
}
if self.tab_drop.is_some() {
return Err("tab_drop is only accepted for tab-drop");
}
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-");
let valid_delta = self.delta.is_some_and(|delta| {
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()
{
return Err("invalid page camera input");
}
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))
{
return Err("page-zoom requires an anchor and positive scale factor");
}
return Ok(());
}
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 self.delta.is_some() {
return Err("delta is only accepted for scroll input");
}
match (self.point, self.press_point) {
(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) =>
{
Ok(())
}
_ => Err("pointer click requires finite press/release coordinates"),
}
}
}
#[derive(Clone)]
/// 原生输入的有界发送端,后台线程串行发往本地 API;所有窗口共享权威发布状态。
pub struct Client(SyncSender<Input>);
impl Client {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
pub fn new(
proxy: EventLoopProxy<crate::app::AppEvent>,
namespace: Arc<RwLock<crate::document::NamespaceState>>,
) -> anyhow::Result<Self> {
let (sender, receiver) = sync_channel::<Input>(64);
thread::Builder::new()
.name("tessera-native-events".into())
.spawn(move || {
for mut input in receiver {
if let Ok(current) = namespace.read() {
if !refresh_continuous_input(&mut input, &current) {
continue;
}
} else {
continue;
}
if let Err(error) = send(&input) {
let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed {
message: error.to_string(),
});
}
}
})?;
Ok(Self(sender))
}
/// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。
pub fn send(&self, input: Input) -> bool {
self.0.try_send(input).is_ok()
}
}
// Native gestures retain the captured Mount across publications. Refresh only
// the version at dequeue, never session, window or target. HTTP callers and
// discrete actions still receive strict stale-frame rejection.
/// 连续手势出队时只更新有效目标的帧版本;保留原 session、窗口和 Mount,离散事件不自动重试。
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"
) {
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 {
return false;
};
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.canvas_viewport
} else if input.kind.starts_with("scene-") {
mount.scene3d_viewport && mount.camera_controls
} else {
mount.scroll.is_some()
}
})
{
return false;
}
input.layout_version = frame.layout_version;
true
}
/// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。
fn send(input: &Input) -> anyhow::Result<()> {
let body = serde_json::to_string(input)?;
let mut stream =
TcpStream::connect_timeout(&crate::api::API_ADDRESS.parse()?, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
write!(
stream,
"POST /v1/namespaces/app.main/events HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)?;
let mut response = String::new();
stream.take(32768).read_to_string(&mut response)?;
let status = response.split_whitespace().nth(1).unwrap_or("");
// An old frame's event is deliberately discarded, with no automatic retry.
if status == "409" {
return Ok(());
}
if status != "200" {
let body = response
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or(&response);
anyhow::bail!(
"Native event failed: {}",
body.chars().take(1024).collect::<String>()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() {
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();
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,
};
let mut current = crate::document::NamespaceState {
runtime_session: 912,
document,
..Default::default()
};
for _ in 0..12 {
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"
);
let mut click = original.clone();
click.kind = "click".into();
assert!(refresh_continuous_input(&mut click, &current));
assert_eq!(click.layout_version, original.layout_version);
current.runtime_session = 913;
assert!(!refresh_continuous_input(&mut original.clone(), &current));
current.runtime_session = 912;
current.document.frame.mounts.clear();
assert!(!refresh_continuous_input(&mut original.clone(), &current));
}
#[test]
fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() {
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());
input.kind = "click".into();
assert!(input.validate().is_err());
input.kind = "tab-drop".into();
input.tab_drop.as_mut().unwrap().index = 65;
assert!(input.validate().is_err());
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]
fn legacy_event_window_defaults_but_zero_is_never_a_resource() {
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());
input.window = 0;
assert!(input.validate().is_err());
}
}