Add managed Window resource and native shell operations
Verify Tessera GUI / verify (push) Failing after 7m25s

This commit is contained in:
2026-10-21 17:12:55 +08:00
parent 3ea4bac48c
commit 76e293d3b8
11 changed files with 681 additions and 41 deletions
+7 -1
View File
@@ -166,7 +166,7 @@ candidate and returns 500, retaining the previous document.
This stage supports live mounted UI for both complete programs and Console inputs, with immutable window frames on the existing worker.
Native mouse/keyboard activation and code-driven callbacks share onClick/click. Managed Mount objects expose each occurrence, and property dependencies and measurement caches avoid recomputing clean visual subtrees. Arrangement still traverses occurrences.
Tab chrome/multiple windows, trait definitions and complete visual type/effect contracts, window-size language inputs and process isolation are still being implemented. Worker threads,
Tab chrome/multiple windows, trait definitions and complete visual type/effect contracts and process isolation are still being implemented. Worker threads,
source budgets and VM instruction limits are not a complete untrusted-code sandbox.
## Developer tools
@@ -202,6 +202,12 @@ A source subclass can override `measure(self, mount, constraints): MeasureResult
Mount.bounds() rejects reads during measurement, including old geometry on an existing Mount. The current occurrence's allocated Bounds become available while its arrange method runs. Structural contexts are prepared before layout, geometry is committed after successful layout, and onMount/onUnmount notifications then run before paint/publication. Both layout phases share one read-only VM call and instruction budget; temporary results can be modified, existing business state cannot. Read versions are captured before hooks, so a hook changing a measured dependency schedules another pass before publication. Dependencies are currently tracked per object. Source measurement results are cached by Mount identity, method, child sizes, constraints and actual object reads, including decoding an existing returned MeasureResult. Cache entries contain borrowed identities and versions, adding no GC roots. Mount-reading measurements currently bypass the cache conservatively because object-level tracking cannot distinguish layoutVersion/geometry from stable structure. Global dictionary writes or changes to another field on a read object can also cause conservative misses. Native measurements keep their shape cache, and arrangement still visits each active occurrence. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses; diagnostic statistics are rejected inside read-only rendering because allocation/count observations are not stable layout inputs.
The current native shell is represented by a managed `Window: Object` resource. `ui.window()` returns it, and an open `page.window()` returns the same object; a closed/unregistered Page returns nil. `page.open(window)` explicitly targets this runtime's native window (omission or nil selects it). `window.pages()` returns a traced ObjectList in open order; closing removes the Page from both window-owned roots and reopening appends it. Window is a host resource, while displayed content remains Page/Block. The current host supplies one window; Window() construction, additional native windows and Tab chrome remain future work.
`window.resize(width, height)`, `setTitle(text)` and `focus()` project requests to the GUI thread. `id()` returns an opaque runtime resource ID and `viewport()` reports actual logical dimensions. Requests are asynchronous and coalesced by kind; queried dimensions change when the native window reports them, and platform bounds can adjust requested sizes. This host accepts sizes from 1 to 4096 logical pixels per axis, with a native minimum of 720 by 480. Titles are UTF-8 text up to 4096 bytes with no control characters. See [window.endor](examples/window.endor).
Commands remain local until the input/candidate succeeds. Failed inputs discard pending commands; failed PUT candidates preserve the live native window. Accepted projections carry the runtime session, so replacement/reset/discard clears old requests. GUI publication and native operations are serialized through the namespace boundary. Read-only rendering rejects window side effects. The private transport queue contains only resource IDs/scalars, introduces no Page roots, and is drained without incrementing object versions. This is single-window resource control; tab UI, move/detach, native window creation and window-close lifecycle still need implementation.
The native window publishes its current logical content size to the existing VM worker on startup, resize and scale changes. The worker coalesces the latest size, updates the managed viewport input, and refreshes the same runtime without adding Console source/history entries. ui.viewport() returns a fresh managed width/height snapshot; reads during rendering depend on the private input object. User changes to a returned snapshot do not resize the window. Unchanged sizes cause no frame; a fresh or unopened Console runtime updates its inputs without replacing a preserved displayed page. Failed resize layouts stop the affected runtime and preserve the displayed frame.
The default Column/Button policy propagates available cross-axis width minus padding and leaves height unconstrained; Row propagates available height and leaves width unconstrained. Page uses both viewport bounds. Native and source desired sizes are clamped to their supplied constraints, while source arrange can still allocate explicit child bounds. The window clips overflow and pointer targeting to its extent; container scrolling, wrapping and general multi-pass constraint negotiation remain future work. See [layout-cache.endor](examples/layout-cache.endor). GET code also exposes X-Tessera-Viewport-Width/Height headers without evaluating source.
+1
View File
@@ -2,6 +2,7 @@
| File | Purpose |
| --- | --- |
| [window.endor](window.endor) | A managed native Window resizes the actual shell; source callbacks update it and layout follows reported dimensions. |
| [layout-cache.endor](layout-cache.endor) | Per-occurrence source measurement caches, separate size/paint models, and a band using the logical viewport input. |
| [source-layout.endor](source-layout.endor) | Source measurement reads its initial Mount parent; source arrangement moves a child and a click updates the shared layout model. |
| [source-hit.endor](source-hit.endor) | Source circle hit testing, ordinary Block click subscriptions and fallback to the parent through transparent space. |
+26
View File
@@ -0,0 +1,26 @@
class WindowActions {
window: Window
function compact(self: WindowActions, event: Event): Unit {
self.window.resize(900, 600)
}
function wide(self: WindowActions, event: Event): Unit {
self.window.resize(1120, 720)
}
}
class WindowBand: RectBlock {
function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(constraints.maxWidth, 32)
}
}
window = ui.window()
actions = WindowActions(window)
band = WindowBand(80, 32, 0, "#237A45")
compact = Button("紧凑窗口 900 × 600")
wide = Button("宽窗口 1120 × 720")
compactConnection = compact.on("click", actions.compact)
wideConnection = wide.on("click", actions.wide)
page = Page()
page.render(Column(24, 16, TextBlock("代码与 GUI 操作同一个受管窗口对象"), band, compact, wide))
page.open(window)
window.setTitle("Endor Window")
window.resize(1120, 720)
+1
View File
@@ -13,6 +13,7 @@
| mount.rs | Managed per-occurrence contexts, weak reverse links, ordered mount/unmount hooks, geometry and focus queries |
| events.rs | Bounded native input transport to the owning VM |
| event_dispatch.rs | Managed Event, weak Subscription tokens, Block-owned connections and occurrence-specific capture/target/bubble through a budgeted VM driver |
| window.rs | Managed native Window resource, ordered Page roots, read-only guards and coalesced scalar GUI projections |
| vm_sequence.rs | Shared validated call-sequence builder for event and lifecycle batches in the existing VM frame loop |
| source_layout.rs | Read-only source layout, per-Mount measurement caches with subcall dependencies, viewport constraints, typed/slot validation and occurrence geometry |
| source_hit.rs | Read-only source/native pointer target selectors, reverse paint order, strict Bool results and matching press/release targets |
+62 -20
View File
@@ -441,17 +441,31 @@ impl Worker {
return;
}
let id = runtime.id;
let result = catch_unwind(AssertUnwindSafe(|| runtime.resize(viewport)));
let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
let document = runtime.resize(viewport)?;
Ok((document, runtime.take_window_commands()?))
}));
let mut current = match namespace.write() {
Ok(current) => current,
Err(_) => return,
};
if current.submission != epoch || current.document.frame.session != id {
if current.submission != epoch || current.runtime_session != id {
return;
}
let document = match result {
Ok(Ok(Some(document))) => document,
Ok(Ok(None)) => return,
let (document, commands) = match result {
Ok(Ok((document, commands))) => {
if let Some(document) = document {
(document, Some(commands))
} else {
let projected = !commands.is_empty();
current.publish_window_commands(id, commands);
drop(current);
if projected {
notify();
}
return;
}
}
failure => {
let mut document = current.document.clone();
document.frame.fault = Some(match failure {
@@ -461,13 +475,18 @@ impl Worker {
.collect(),
_ => {
self.repl = None;
current.runtime_session = 0;
current.window_commands.clear();
"Runtime discarded after an internal viewport failure".into()
}
});
document
(document, None)
}
};
let changed = matches!(current.commit(epoch, document), Ok(Some(_)));
if changed && let Some(commands) = commands {
current.publish_window_commands(id, commands);
}
drop(current);
if changed {
notify();
@@ -499,6 +518,8 @@ impl Worker {
if let Ok(mut current) = namespace.write() {
current.document.frame.session = 0;
current.document.frame.focus = None;
current.runtime_session = 0;
current.window_commands.clear();
}
notify();
return json(
@@ -547,21 +568,32 @@ impl Worker {
.as_ref()
.is_none_or(|(current, _)| *current != epoch)
{
self.next_session += 1;
self.next_session = match self.next_session.checked_add(1) {
Some(id) => id,
None => return error_response(StatusCode(500), "session identities exhausted"),
};
let viewport = namespace.read().ok().and_then(|current| current.viewport);
match endor_host::ReplSession::new_with_viewport(self.next_session, viewport) {
Ok(session) => self.repl = Some((epoch, session)),
Ok(session) => {
if let Ok(mut current) = namespace.write()
&& current.submission == epoch
{
current.publish_window_commands(session.id, Vec::new());
}
self.repl = Some((epoch, session));
}
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
}
let session = &mut self.repl.as_mut().expect("initialized REPL").1;
let id = session.id;
let result = catch_unwind(AssertUnwindSafe(|| {
if let Some(event) = &event {
session.dispatch(event)
let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
let result = if let Some(event) = &event {
session.dispatch(event)?
} else {
session.execute(&job.source, &job.namespace)
}
session.execute(&job.source, &job.namespace)?
};
Ok((result, session.take_window_commands()?))
}));
let faulted = session.is_faulted();
let Ok(mut current) = namespace.write() else {
@@ -573,7 +605,8 @@ impl Worker {
return superseded(job.submission);
}
match result {
Ok(Ok(result)) => {
Ok(Ok((result, commands))) => {
let projected = !commands.is_empty();
let rendered = result.document.is_some();
if let Some(document) = result.document {
match current.commit(epoch, document) {
@@ -582,9 +615,10 @@ impl Worker {
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
}
current.publish_window_commands(id, commands);
let revision = current.revision;
drop(current);
if rendered {
if rendered || projected {
notify();
}
json(
@@ -606,6 +640,8 @@ impl Worker {
let mut document = current.document.clone();
document.frame.fault =
Some("Runtime discarded after an internal execution failure".into());
current.runtime_session = 0;
current.window_commands.clear();
let _ = current.commit(epoch, document);
drop(current);
self.repl = None;
@@ -640,17 +676,19 @@ impl Worker {
.read()
.map_err(|_| anyhow::anyhow!("namespace lock poisoned"))?
.viewport;
if viewport.is_some() {
let (runtime, document) = if viewport.is_some() {
endor_host::ReplSession::load_with_viewport(
&job.source,
&job.namespace,
job.submission,
id,
viewport,
)
)?
} else {
endor_host::ReplSession::load(&job.source, &job.namespace, job.submission, id)
}
endor_host::ReplSession::load(&job.source, &job.namespace, job.submission, id)?
};
let commands = runtime.take_window_commands()?;
Ok::<_, anyhow::Error>((runtime, document, commands))
}));
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock poisoned");
@@ -659,8 +697,10 @@ impl Worker {
return superseded(job.submission);
}
match result {
Ok(Ok((runtime, document))) => match current.commit(job.submission, document) {
Ok(Ok((runtime, document, commands))) => match current.commit(job.submission, document)
{
Ok(Some(revision)) => {
current.publish_window_commands(id, commands);
self.repl = Some((job.submission, runtime));
drop(current);
notify();
@@ -848,6 +888,8 @@ declarations, explicit first `self` parameters, positional field construction an
member assignment. Inherited methods bind the actual receiver; overridden methods
are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Traits and annotation checking remain pending. Native Block/Page methods use the same type method tables and binding path.
Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), setTitle(text) and focus(). Requests project to the actual main native window only after successful input/candidate completion and carry the current runtime session. Resize is asynchronous and the reported viewport is authoritative; this host accepts 1..4096 logical dimensions with a native 720x480 minimum. Failed input/candidates discard pending requests and read-only rendering rejects window effects. Additional windows, Window() construction, tab UI and detach/move remain pending.
Source measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. General constraint negotiation, wrapping and scrolling remain pending.
Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects.
+34 -1
View File
@@ -75,6 +75,33 @@ impl TesseraApp {
})
}
fn apply_window_commands(&self) {
let Some(window) = &self.window else {
return;
};
let Ok(mut namespace) = self.namespace.write() else {
return;
};
let commands = std::mem::take(&mut namespace.window_commands);
for envelope in commands {
if envelope.session == 0
|| envelope.session != namespace.runtime_session
|| envelope.command.window != crate::window::MAIN_WINDOW
{
continue;
}
match envelope.command.action {
crate::window::Action::Resize { width, height } => {
let _ = window.request_inner_size(LogicalSize::new(width, height));
}
crate::window::Action::SetTitle(title) => window.set_title(&title),
crate::window::Action::Focus => window.focus_window(),
}
}
drop(namespace);
self.publish_viewport();
}
fn publish_viewport(&self) {
let Some(window) = &self.window else {
return;
@@ -110,6 +137,7 @@ impl TesseraApp {
}
fn document_updated(&mut self) {
self.apply_window_commands();
if let Ok(namespace) = self.namespace.read() {
self.developer_tools.document_updated(&namespace.document);
let frame = &namespace.document.frame;
@@ -373,7 +401,11 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
let attributes = WindowAttributes::default()
.with_title("Tessera")
.with_inner_size(LogicalSize::new(1_280.0, 800.0))
.with_min_inner_size(LogicalSize::new(720.0, 480.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,
));
let window = Arc::new(
event_loop
.create_window(attributes)
@@ -387,6 +419,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
self.window = Some(window.clone());
self.context = Some(context);
self.surface = Some(surface);
self.apply_window_commands();
self.publish_viewport();
window.request_redraw();
}
+56
View File
@@ -19,6 +19,8 @@ pub struct NamespaceState {
pub submission: u64,
pub document: RenderDocument,
pub viewport: Option<crate::layout::Size>,
pub runtime_session: u64,
pub window_commands: Vec<crate::window::Envelope>,
}
impl Default for NamespaceState {
@@ -29,11 +31,32 @@ impl Default for NamespaceState {
submission: 0,
document: RenderDocument::default(),
viewport: None,
runtime_session: 0,
window_commands: Vec::new(),
}
}
}
impl NamespaceState {
pub fn publish_window_commands(&mut self, session: u64, commands: Vec<crate::window::Command>) {
if self.runtime_session != session {
self.window_commands.clear();
}
self.runtime_session = session;
if session == 0 {
self.window_commands.clear();
return;
}
for command in commands {
self.window_commands
.retain(|old| !old.command.same_kind(&command));
self.window_commands
.push(crate::window::Envelope { session, command });
}
self.window_commands
.sort_by_key(|command| command.command.sequence);
}
pub fn next_submission(&self) -> Result<u64> {
self.submission
.checked_add(1)
@@ -61,6 +84,8 @@ impl NamespaceState {
.context("revision counter exhausted")?;
self.submission = submission;
self.document = RenderDocument::default();
self.runtime_session = 0;
self.window_commands.clear();
self.revision = revision;
Ok(revision)
}
@@ -477,3 +502,34 @@ mod tests {
);
}
}
#[cfg(test)]
mod window_projection_tests {
use super::*;
#[test]
fn replacement_and_reset_discard_old_window_projections() {
let mut state = NamespaceState::default();
let command = crate::window::Command {
window: 1,
sequence: 1,
action: crate::window::Action::Focus,
};
state.publish_window_commands(1, vec![command.clone()]);
let resize = crate::window::Command {
window: 1,
sequence: 2,
action: crate::window::Action::Resize {
width: 900.0,
height: 600.0,
},
};
state.publish_window_commands(1, vec![resize.clone()]);
assert_eq!(state.window_commands.len(), 2);
state.publish_window_commands(2, vec![resize]);
assert_eq!(state.window_commands.len(), 1);
assert_eq!(state.window_commands[0].session, 2);
state.clear().unwrap();
assert_eq!(state.runtime_session, 0);
assert!(state.window_commands.is_empty());
}
}
+144
View File
@@ -454,6 +454,7 @@ impl ReplSession {
})
})();
if result.is_err() {
self.take_window_commands()?;
self.bridge.discard_pending(&mut self.candidate.vm)?;
if let Some(node) = self.nodes.last_mut() {
node.name
@@ -510,6 +511,10 @@ impl ReplSession {
result
}
pub fn take_window_commands(&self) -> Result<Vec<crate::window::Command>> {
self.bridge.take_window_commands(&self.candidate.vm)
}
pub fn resize(
&mut self,
viewport: Option<crate::layout::Size>,
@@ -522,6 +527,7 @@ impl ReplSession {
}
let result = self.refresh_document();
if result.is_err() {
self.take_window_commands()?;
self.bridge.discard_pending(&mut self.candidate.vm)?;
}
self.bridge
@@ -556,6 +562,7 @@ impl ReplSession {
})
})();
if result.is_err() {
self.take_window_commands()?;
self.faulted = true;
self.bridge.discard_pending(&mut self.candidate.vm)?;
}
@@ -838,6 +845,143 @@ mod tests {
const EXAMPLE: &str = include_str!("../examples/hello.endor");
const PAGE: &str = include_str!("../examples/page.endor");
#[test]
fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_requests() {
let mut session = ReplSession::new(111).unwrap();
session
.execute(
r#"window = ui.window()
window.resize(900,600)
window.resize(1000,700)
window.setTitle("Endor")
window.focus()"#,
"app.main",
)
.unwrap();
let requests = session.take_window_commands().unwrap();
assert_eq!(requests.len(), 3);
assert!(matches!(
requests[0].action,
crate::window::Action::Resize {
width: 1000.0,
height: 700.0
}
));
assert!(
matches!(&requests[1].action,crate::window::Action::SetTitle(title) if title=="Endor")
);
assert!(session.take_window_commands().unwrap().is_empty());
assert!(
session
.execute("window.resize(1400,900)\nwindow.resize(0,600)", "app.main")
.is_err()
);
assert!(session.take_window_commands().unwrap().is_empty());
assert!(session.execute("window.resize(0,600)", "app.main").is_err());
assert!(session.take_window_commands().unwrap().is_empty());
}
#[test]
fn window_page_roots_follow_open_close_and_creation_order() {
let source = r#"first = Page()
second = Page()
first.render(TextBlock("first"))
second.render(TextBlock("second"))
window = ui.window()"#;
let (mut session, _) = ReplSession::load(source, "app.main", 112, 1).unwrap();
assert_eq!(
session
.execute("window.pages().count", "app.main")
.unwrap()
.value,
"2"
);
let first = session.execute("first.window()", "app.main").unwrap().value;
assert_eq!(
first,
session
.execute("second.window()", "app.main")
.unwrap()
.value
);
session.execute("first.close()", "app.main").unwrap();
assert_eq!(
session.execute("first.window()", "app.main").unwrap().value,
"nil"
);
assert_eq!(
session
.execute("window.pages().count", "app.main")
.unwrap()
.value,
"1"
);
session.execute("first.open(window)", "app.main").unwrap();
assert_eq!(
session
.execute("window.pages().count", "app.main")
.unwrap()
.value,
"2"
);
assert_eq!(
session
.execute("window.pages().at(0)", "app.main")
.unwrap()
.value,
session.execute("second", "app.main").unwrap().value
);
assert!(session.execute("first.open(Object())", "app.main").is_err());
}
#[test]
fn closed_page_is_not_kept_alive_by_window_page_order() {
let (mut session, _) = ReplSession::load(
"page = Page()\npage.render(TextBlock(\"page\"))",
"app.main",
113,
1,
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(page) = session
.candidate
.vm
.dict_get(globals, &Value::Text("page".into()))
.unwrap()
.unwrap()
else {
panic!("Page")
};
let weak = session.candidate.vm.downgrade(page).unwrap();
session.execute("page.close()", "app.main").unwrap();
session
.candidate
.vm
.dict_delete(globals, &Value::Text("page".into()))
.unwrap();
session.candidate.vm.collect_cycles();
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
}
#[test]
fn rendering_cannot_enqueue_native_window_side_effects() {
let source = r##"class BadRect: RectBlock {
function measure(self: BadRect, mount: Mount, constraints: Constraints): MeasureResult {
ui.window().resize(900,600)
return MeasureResult(80,20)
}
}
page = Page()
page.render(BadRect(80,20,0,"#DDEEFF"))"##;
let error = ReplSession::load(source, "app.main", 114, 1).err().unwrap();
assert!(
error
.to_string()
.contains("window operations are forbidden")
);
}
fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 {
let Value::Object(function) = session
.candidate
+1
View File
@@ -26,6 +26,7 @@ mod system_ui;
mod text;
mod ui_bridge;
mod vm_sequence;
mod window;
use std::sync::{Arc, RwLock};
+65 -19
View File
@@ -29,6 +29,7 @@ pub const TYPE_NAMES: &[&str] = &[
"Column",
"Button",
"Page",
"Window",
"Mount",
"Event",
"Subscription",
@@ -195,6 +196,8 @@ enum Operation {
Mounts,
Stats,
Viewport,
Window,
PageWindow,
State,
StateSet,
Increment,
@@ -440,11 +443,8 @@ fn new_typed_block(
fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> {
let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?;
cx.dict_set(
state.tabs,
Value::Text(page.to_string()),
Value::Object(page),
)?;
let window = object(Some(&cx.store().get_attr(state.desktop, "window")?))?;
crate::window::register_page(cx, window, page)?;
cx.set_attr(state.desktop, "pending", Value::Object(page))?;
state.pending = Some(Some(page));
Ok(())
@@ -496,7 +496,7 @@ impl NativeCallable for Function {
use Operation::*;
let (minimum, maximum) = match self.operation {
IsInstance | IsSubtype => (2, 2),
Stats | Viewport | Mounts => (0, 0),
Stats | Viewport | Window | PageWindow | Mounts => (0, 0),
State | StateSet => (1, 1),
Increment => (0, 0),
TextOf => (2, 4),
@@ -507,7 +507,8 @@ impl NativeCallable for Function {
Row | Column => (2, MAX_UI_NODES + 2),
Button => (1, 1),
Replace | Move => (2, 2),
Page | Unmount | Open | Close | Activate | Back | Forward | Children => (0, 0),
Page | Unmount | Close | Activate | Back | Forward | Children => (0, 0),
Open => (0, 1),
Click | Invalidate => (0, 1),
On => (2, 3),
Dispatch => (1, 2),
@@ -608,6 +609,16 @@ impl NativeCallable for Function {
)?;
return Ok(NativeResult::Return(Value::Object(set)));
}
if matches!(self.operation, Window) {
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
return Ok(NativeResult::Return(
cx.store().get_attr(desktop, "window")?,
));
}
if matches!(self.operation, Viewport) {
let desktop = self
.session
@@ -939,7 +950,34 @@ impl NativeCallable for Function {
}];
show(cx, owner, &self.session)?;
}
PageWindow => {
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
let window = object(Some(&cx.store().get_attr(desktop, "window")?))?;
let value = if crate::window::pages(cx.store(), window)?.contains(&owner) {
Value::Object(window)
} else {
Value::Nil
};
return Ok(NativeResult::Return(value));
}
Open | Activate => {
if let Some(requested) = args.first()
&& !matches!(requested, Value::Nil)
{
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
let window = cx.store().get_attr(desktop, "window")?;
if requested != &window {
return Err(invalid("Page.open requires this runtime's native Window"));
}
}
show(cx, owner, &self.session)?;
return Ok(NativeResult::Return(Value::Object(owner)));
}
@@ -951,19 +989,10 @@ impl NativeCallable for Function {
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
cx.dict_delete(session.tabs, &Value::Text(owner.to_string()))?;
let window = object(Some(&cx.store().get_attr(session.desktop, "window")?))?;
crate::window::unregister_page(cx, window, owner)?;
if session.active == Some(owner) || session.pending == Some(Some(owner)) {
let next =
cx.store()
.dict_items(session.tabs)?
.first()
.and_then(|(_, value)| {
if let Value::Object(page) = value {
Some(*page)
} else {
None
}
});
let next = crate::window::pages(cx.store(), window)?.first().copied();
cx.set_attr(
session.desktop,
"pending",
@@ -1050,6 +1079,18 @@ pub struct UiBridge {
}
impl UiBridge {
pub fn take_window_commands(&self, vm: &Vm) -> Result<Vec<crate::window::Command>> {
let desktop = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
.desktop;
let Value::Object(window) = vm.get_attr(desktop, "window")? else {
bail!("native Window is unavailable");
};
Ok(crate::window::take_commands(vm, window)?)
}
pub fn set_viewport(&self, vm: &mut Vm, viewport: Option<crate::layout::Size>) -> Result<bool> {
if let Some(size) = viewport
&& (!size.width.is_finite()
@@ -1493,6 +1534,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("textOf", Operation::TextOf),
("stats", Operation::Stats),
("viewport", Operation::Viewport),
("window", Operation::Window),
] {
let function = vm.allocate(ContextNativeFunction::new(
name,
@@ -1526,6 +1568,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Column", "ContainerBlock", Some("column")),
("Button", "ContainerBlock", Some("button")),
("Page", "ContainerBlock", Some("page")),
("Window", "Object", None),
("Mount", "Object", None),
("Event", "Object", Some("event")),
("Subscription", "Object", None),
@@ -1645,6 +1688,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
"textOf",
"stats",
"viewport",
"window",
"isInstance",
"isSubtype",
] {
@@ -1672,6 +1716,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Page", "render", Operation::Render),
("Page", "unmount", Operation::Unmount),
("Page", "open", Operation::Open),
("Page", "window", Operation::PageWindow),
("Page", "close", Operation::Close),
("Page", "activate", Operation::Activate),
("Page", "navigate", Operation::Navigate),
@@ -1692,6 +1737,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.attach_type(method, function_type)?;
vm.define_method(expected, name, method)?;
}
crate::window::install(vm, roots, desktop, tabs)?;
let block_type = object(Some(&vm.get_attr(ui, "Block")?))?;
crate::native_visual::install(vm, roots, desktop, block_type, function_type)?;
let event_type = object(Some(&vm.get_attr(ui, "Event")?))?;
+284
View File
@@ -0,0 +1,284 @@
//! 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),
Focus,
}
#[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,
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.tabs);
for page in &self.pages {
visit(*page);
}
}
}
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
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,
SetTitle,
Focus,
}
#[derive(Debug)]
struct Method {
operation: Operation,
desktop: ObjectRef,
}
impl NativeCallable for Method {
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.id != MAIN_WINDOW {
return Err(invalid("native window is unavailable"));
}
let expected = match self.operation {
Operation::Resize => 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(self.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::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))
}
}
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.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(())
}
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),
("setTitle", Operation::SetTitle),
("focus", Operation::Focus),
] {
let function = vm.allocate(ContextNativeFunction::new(
name,
Method { operation, desktop },
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,
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))?;
Ok(window)
}