Files
endorphin-tessera-gui/src/model/window.rs
2026-10-25 21:25:43 +08:00

487 lines
16 KiB
Rust

//! Managed window resources and versioned, coalesced native projections.
//! The VM owns Page roots; only scalar commands cross to the GUI thread.
use crate::{native_visual, object_list, ui_bridge::desktop_type};
use endor_core::{
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
ObjectStore, Value, Vm, VmError,
};
use std::sync::{Arc, Mutex};
pub const MAIN_WINDOW: u64 = 1;
pub const MAX_WINDOW_DIMENSION: f64 = 4096.0;
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
Resize { width: f64, height: f64 },
SetTitle(String),
MoveTo { x: i32, y: i32 },
Focus,
Close,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Command {
pub window: u64,
pub sequence: u64,
pub action: Action,
}
impl Command {
pub fn same_kind(&self, other: &Self) -> bool {
self.window == other.window
&& std::mem::discriminant(&self.action) == std::mem::discriminant(&other.action)
}
}
#[derive(Debug, Clone)]
pub struct Envelope {
pub session: u64,
pub command: Command,
}
#[derive(Debug, Clone)]
pub struct ManagedWindow {
pub id: u64,
pub desktop: ObjectRef,
pub closed: bool,
pub tasks: Option<ObjectRef>,
tabs: ObjectRef,
pages: Vec<ObjectRef>,
sequence: u64,
// Transport state only. Draining it must not change a language object's
// version or create a render-dependency feedback loop.
outbox: Arc<Mutex<Vec<Command>>>,
}
impl ObjectPayload for ManagedWindow {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn kind_name(&self) -> &'static str {
"tessera-window"
}
fn allows_attributes(&self) -> bool {
true
}
fn reference_assignment_only(&self) -> bool {
true
}
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
visit(self.desktop);
if let Some(tasks) = self.tasks {
visit(tasks);
}
visit(self.tabs);
for page in &self.pages {
visit(*page);
}
}
}
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
fn screen_coordinate(value: &Value) -> Result<i32, VmError> {
let Value::Number(value) = value else {
return Err(invalid("screen position requires integer coordinates"));
};
value
.as_i64()
.filter(|number| (-1_000_000..=1_000_000).contains(number))
.and_then(|number| i32::try_from(number).ok())
.ok_or(invalid("screen position exceeds limits"))
}
fn scalar(value: &Value) -> Result<f64, VmError> {
let Value::Number(value) = value else {
return Err(invalid("window size requires numbers"));
};
let number = value
.to_string()
.parse::<f64>()
.map_err(|_| invalid("invalid window size"))?;
if !number.is_finite() || !(1.0..=MAX_WINDOW_DIMENSION).contains(&number) {
return Err(invalid("window size exceeds limits"));
}
Ok(number)
}
#[derive(Debug, Clone, Copy)]
enum Operation {
Id,
Viewport,
Pages,
Resize,
MoveTo,
SetTitle,
Focus,
}
#[derive(Debug)]
struct Method {
signature: endor_core::CallSignature,
operation: Operation,
desktop: ObjectRef,
}
impl NativeCallable for Method {
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(self.signature.clone())
}
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(match self.operation {
Operation::Resize | Operation::MoveTo => 3,
Operation::SetTitle => 2,
_ => 1,
}))
}
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let Some(Value::Object(owner)) = args.first() else {
return Err(invalid("Window method requires a Window"));
};
let data = cx
.store()
.payload::<ManagedWindow>(*owner, "Window")?
.clone();
if data.closed && !matches!(self.operation, Operation::Id | Operation::Pages) {
return Err(invalid("native window is closed"));
}
let expected = match self.operation {
Operation::Resize | Operation::MoveTo => 3,
Operation::SetTitle => 2,
_ => 1,
};
if args.len() != expected {
return Err(invalid("incorrect Window method argument count"));
}
let result = match self.operation {
Operation::Id => Value::Number((data.id as i64).into()),
Operation::Pages => {
Value::Object(object_list::collection(cx, data.pages, self.desktop)?)
}
Operation::Viewport => {
let Value::Object(input) = cx.store().get_attr(data.desktop, "viewport")? else {
return Err(invalid("window viewport is unavailable"));
};
let width = cx.store().get_attr(input, "width")?;
let height = cx.store().get_attr(input, "height")?;
Value::Object(native_visual::record(
cx,
self.desktop,
"MeasureResult",
vec![("width", width), ("height", height)],
)?)
}
operation => {
if cx.store().is_read_only() {
return Err(invalid(
"window operations are forbidden during read-only rendering",
));
}
let action = match operation {
Operation::MoveTo => Action::MoveTo {
x: screen_coordinate(&args[1])?,
y: screen_coordinate(&args[2])?,
},
Operation::Resize => Action::Resize {
width: scalar(&args[1])?,
height: scalar(&args[2])?,
},
Operation::SetTitle => {
let Value::Text(title) = &args[1] else {
return Err(invalid("window title requires text"));
};
if title.len() > 4096 || title.chars().any(char::is_control) {
return Err(invalid("invalid window title"));
}
Action::SetTitle(title.clone())
}
Operation::Focus => Action::Focus,
_ => unreachable!(),
};
enqueue(cx, *owner, action)?;
Value::Nil
}
};
Ok(NativeResult::Return(result))
}
}
pub fn enqueue(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
action: Action,
) -> Result<(), VmError> {
let mut data = cx
.store()
.payload::<ManagedWindow>(owner, "Window")?
.clone();
data.sequence = data
.sequence
.checked_add(1)
.ok_or(invalid("window command sequence exhausted"))?;
let command = Command {
window: data.id,
sequence: data.sequence,
action,
};
let queue = data.outbox.clone();
let mut queue = queue
.lock()
.map_err(|_| invalid("window outbox poisoned"))?;
// The heap barrier succeeds before any external transport state changes.
cx.replace_payload(owner, data)?;
queue.retain(|old| !old.same_kind(&command));
queue.push(command);
Ok(())
}
pub fn register_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
page: ObjectRef,
) -> Result<(), VmError> {
if cx.store().is_read_only() {
return Err(invalid(
"opening a Page is forbidden during read-only rendering",
));
}
let mut data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
if data.closed {
return Err(invalid("native window is closed"));
}
if !data.pages.contains(&page) {
data.pages.push(page);
cx.dict_set(
data.tabs,
Value::Text(page.to_string()),
Value::Object(page),
)?;
cx.replace_payload(window, data)?;
}
Ok(())
}
pub fn unregister_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
page: ObjectRef,
) -> Result<(), VmError> {
if cx.store().is_read_only() {
return Err(invalid(
"closing a Page is forbidden during read-only rendering",
));
}
let mut data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
if !data.pages.contains(&page) {
return Ok(());
}
data.pages.retain(|value| *value != page);
cx.dict_delete(data.tabs, &Value::Text(page.to_string()))?;
cx.replace_payload(window, data)?;
Ok(())
}
/// Discard an unpublished Page registration without invoking guest callbacks.
pub fn unregister_page_host(
vm: &mut Vm,
window: ObjectRef,
page: ObjectRef,
) -> Result<(), VmError> {
let mut data = vm.payload::<ManagedWindow>(window, "Window")?.clone();
if data.pages.contains(&page) {
data.pages.retain(|value| *value != page);
vm.dict_delete(data.tabs, &Value::Text(page.to_string()))?;
vm.replace_payload(window, data)?;
}
Ok(())
}
pub fn move_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
page: ObjectRef,
position: usize,
) -> Result<(), VmError> {
let mut data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
if data.closed {
return Err(invalid("native window is closed"));
}
let from = data
.pages
.iter()
.position(|candidate| *candidate == page)
.ok_or(invalid("Page is not open in this window"))?;
if position >= data.pages.len() {
return Err(invalid("tab position out of range"));
}
if cx.store().is_read_only() {
return Err(invalid(
"tab ordering is forbidden during read-only rendering",
));
}
if from != position {
let page = data.pages.remove(from);
data.pages.insert(position, page);
cx.replace_payload(window, data)?;
}
Ok(())
}
pub fn pages(store: &ObjectStore, window: ObjectRef) -> Result<&[ObjectRef], VmError> {
Ok(&store.payload::<ManagedWindow>(window, "Window")?.pages)
}
pub fn take_commands(vm: &Vm, window: ObjectRef) -> Result<Vec<Command>, VmError> {
let data = vm.payload::<ManagedWindow>(window, "Window")?;
let mut queue = data
.outbox
.lock()
.map_err(|_| invalid("window outbox poisoned"))?;
let mut commands = std::mem::take(&mut *queue);
commands.sort_by_key(|command| command.sequence);
Ok(commands)
}
pub fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
desktop: ObjectRef,
tabs: ObjectRef,
) -> Result<ObjectRef, VmError> {
let definition = desktop_type(vm.object_store(), "Window", desktop)?;
let function_type = desktop_type(vm.object_store(), "Function", desktop)?;
for (name, operation) in [
("id", Operation::Id),
("viewport", Operation::Viewport),
("pages", Operation::Pages),
("resize", Operation::Resize),
("moveTo", Operation::MoveTo),
("setTitle", Operation::SetTitle),
("focus", Operation::Focus),
] {
use endor_core::{AnnotationType as T, CallEffect, CallSignature};
let (parameters, returns, effect) = match operation {
Operation::Id => (vec![], T::Number, CallEffect::ReadOnly),
Operation::Pages => (
vec![],
T::Nominal(desktop_type(vm.object_store(), "ObjectList", desktop)?),
CallEffect::ReadOnly,
),
Operation::Viewport => (
vec![],
T::Nominal(desktop_type(vm.object_store(), "MeasureResult", desktop)?),
CallEffect::ReadOnly,
),
Operation::Resize | Operation::MoveTo => {
(vec![T::Number, T::Number], T::Unit, CallEffect::Mutating)
}
Operation::SetTitle => (vec![T::String], T::Unit, CallEffect::Mutating),
Operation::Focus => (vec![], T::Unit, CallEffect::Mutating),
};
let mut arguments = vec![T::Nominal(definition)];
arguments.extend(parameters);
let function = vm.allocate(ContextNativeFunction::new(
name,
Method {
operation,
desktop,
signature: CallSignature::exact(arguments, returns, effect),
},
vec![desktop],
))?;
roots.push(function);
vm.attach_type(function, function_type)?;
vm.define_method(definition, name, function)?;
}
let window = vm.allocate(ManagedWindow {
id: MAIN_WINDOW,
desktop,
closed: false,
tasks: None,
tabs,
pages: Vec::new(),
sequence: 0,
outbox: Arc::new(Mutex::new(Vec::new())),
})?;
roots.push(window);
vm.attach_type(window, definition)?;
vm.set_attr(desktop, "window", Value::Object(window))?;
let windows = vm.new_dict()?;
vm.set_attr(desktop, "windows", Value::Object(windows))?;
vm.dict_set(
windows,
Value::Text(MAIN_WINDOW.to_string()),
Value::Object(window),
)?;
vm.release(windows)?;
Ok(window)
}
/// Create a managed native-window resource. The GUI creates the OS resource only
/// after the owning candidate/input publishes its immutable projection.
pub fn create(
cx: &mut NativeContext<'_>,
master: ObjectRef,
id: u64,
definition: ObjectRef,
) -> Result<(ObjectRef, ObjectRef, ObjectRef), VmError> {
if cx.store().is_read_only() {
return Err(invalid(
"window creation is forbidden during read-only rendering",
));
}
let desktop = cx.new_object()?;
let tabs = cx.new_dict()?;
let types = cx.store().get_attr(master, "types")?;
cx.set_attr(desktop, "types", types)?;
cx.set_attr(desktop, "viewport", Value::Nil)?;
cx.set_attr(desktop, "tabs", Value::Object(tabs))?;
let window = cx.allocate(ManagedWindow {
id,
desktop,
closed: false,
tasks: None,
tabs,
pages: Vec::new(),
sequence: 0,
outbox: Arc::new(Mutex::new(Vec::new())),
})?;
cx.attach_type(window, definition)?;
cx.set_attr(desktop, "window", Value::Object(window))?;
let Value::Object(windows) = cx.store().get_attr(master, "windows")? else {
return Err(invalid("window registry missing"));
};
cx.dict_set(windows, Value::Text(id.to_string()), Value::Object(window))?;
Ok((window, desktop, tabs))
}
pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmError> {
if cx.store().is_read_only() {
return Err(invalid(
"window close is forbidden during read-only rendering",
));
}
let mut data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
if data.closed {
return Ok(());
}
crate::tasks::cancel_owner(cx, window)?;
for page in data.pages.clone() {
crate::tasks::cancel_owner(cx, page)?;
unregister_page(cx, window, page)?;
}
data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
data.closed = true;
cx.replace_payload(window, data)?;
enqueue(cx, window, Action::Close)
}
/// Host teardown for a stopped VM: no binding, dispatch or source execution.
pub fn retire(vm: &mut Vm, window: ObjectRef) -> Result<(), VmError> {
let mut data = vm.payload::<ManagedWindow>(window, "Window")?.clone();
vm.dict_clear(data.tabs)?;
data.pages.clear();
data.closed = true;
vm.replace_payload(window, data)
}