Cache source measurements and propagate native viewport inputs
Verify Tessera GUI / verify (push) Failing after 7m29s

This commit is contained in:
2026-10-21 16:33:35 +08:00
parent 1b5fe1069f
commit 3ea4bac48c
12 changed files with 744 additions and 37 deletions
+7 -3
View File
@@ -196,11 +196,15 @@ Native visual methods are now available on Block: `measure(mount, Constraints(mi
Outside a renderer layout call, native measurement queries find intrinsic native subtree size and clamp it to the given constraints. During layout, native base methods use the staged measured children of that occurrence. Native Row/Column arrangement uses child sizes, padding and gaps; it does not stretch children to fill the supplied bounds. Hit testing uses local coordinates against the current Mount dimensions. Coordinates/dimensions must be finite and remain within the native layout limit. Width/height and constraint ranges cannot be negative or inverted. The value constructors preserve fields as ordinary objects; methods validate their contents when used, and global annotation type checking remains pending.
`invalidate("paint")` requests a frame while retaining measurement caches; `invalidate("layout")` also clears affected intrinsic measurements; `invalidate("content")` also invalidates derived content and ancestor dependencies. The default reason is layout. Requests coalesce at the refresh boundary, cover all aliases/occurrences of the same current content and preserve Mount identities. Hidden objects do not force visible frames. See [visual-protocols.endor](examples/visual-protocols.endor). Source measure/arrange overrides now participate in actual layout with structural Mount contexts available before first measurement. Source paint and hitTest also run in the frame/input pipeline. Explicit traits, full type/effect contracts, viewport inputs and further layout caching remain future work.
`invalidate("paint")` requests a frame while retaining measurement caches; `invalidate("layout")` also clears affected intrinsic measurements; `invalidate("content")` also invalidates derived content and ancestor dependencies. The default reason is layout. Requests coalesce at the refresh boundary, cover all aliases/occurrences of the same current content and preserve Mount identities. Hidden objects do not force visible frames. See [visual-protocols.endor](examples/visual-protocols.endor). Source measure/arrange overrides now participate in actual layout with structural Mount contexts available before first measurement. Source paint and hitTest also run in the frame/input pipeline. Explicit traits, full type/effect contracts, more precise context dependencies and general container constraint negotiation remain future work.
A source subclass can override `measure(self, mount, constraints): MeasureResult` and `arrange(self, mount, geometry): Arrangement`. The persistent renderer creates stable Mount identities and parent/child/sibling links before measuring. Measurement proceeds bottom-up with finite unconstrained page bounds (0 to 1000000); native parents and explicit native base calls use the current occurrence's measured child sizes, including source overrides. Arrangement proceeds top-down; each child Bounds must be assigned exactly once using its stable slot, with nominal result types and finite geometry checked. Source arrangements can resize or move children; omission, duplicate/foreign slots and invalid geometry reject the frame. See [source-layout.endor](examples/source-layout.endor).
A source subclass can override `measure(self, mount, constraints): MeasureResult` and `arrange(self, mount, geometry): Arrangement`. The persistent renderer creates stable Mount identities and parent/child/sibling links before measuring. Measurement proceeds bottom-up with the current logical viewport as the Page constraints (headless callers default to 0..1000000); native parents and explicit native base calls use the current occurrence's measured child sizes, including source overrides. Arrangement proceeds top-down; each child Bounds must be assigned exactly once using its stable slot, with nominal result types and finite geometry checked. Source arrangements can resize or move children; omission, duplicate/foreign slots and invalid geometry reject the frame. See [source-layout.endor](examples/source-layout.endor).
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 layout methods run again for each requested frame; native measurements keep the existing shape cache, and a tree without layout overrides uses the native fast path. This is an initial unconstrained layout policy, without viewport constraint propagation, source measurement caching or a raw window-size input API.
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 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.
Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor).
+1
View File
@@ -2,6 +2,7 @@
| File | Purpose |
| --- | --- |
| [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. |
| [source-paint.endor](source-paint.endor) | Source paint overrides render checked commands in the window; a source Button method updates the shared tint model. |
+40
View File
@@ -0,0 +1,40 @@
class WidthModel {
width: Number
function expand(self: WidthModel, event: Event): Unit {
self.width = 260
}
}
class Tint {
color: String
function blue(self: Tint, event: Event): Unit {
self.color = "#526AC7"
}
}
class CachedRect: RectBlock {
dimensions: WidthModel
tint: Tint
function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(self.dimensions.width, 60)
}
function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint.color
return drawing
}
}
class ViewportBand: RectBlock {
function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(ui.viewport().width, 24)
}
}
dimensions = WidthModel(200)
tint = Tint("#ED6A5A")
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
band = ViewportBand(80, 24, 0, "#237A45")
blue = Button("只改绘制颜色")
expand = Button("修改测量依赖")
blueConnection = blue.on("click", tint.blue)
expandConnection = expand.on("click", dimensions.expand)
root = Column(24, 16, TextBlock("改变颜色保留测量缓存;改变宽度重新测量"), band, rect, blue, expand)
page = Page()
page.render(root)
+3 -3
View File
@@ -3,7 +3,7 @@
| Module | Responsibility |
| --- | --- |
| main.rs | Native window event loop, namespace state and API startup |
| api.rs | Code CRUD, live Console/reset and compatibility evaluation endpoints, worker-owned session, request observation and diagnostics |
| api.rs | Code CRUD, live Console/reset and compatibility evaluation endpoints, worker-owned session, request observation, coalesced native viewport updates and diagnostics |
| activity.rs | Bounded in-memory HTTP exchanges with marked previews and allowlisted request headers |
| endor_host.rs | Aster compilation, fresh PUT candidates retained after successful publication, shared Console/program VM, bounded execution, root cleanup and AST developer tree for function/class/member assignment nodes |
| endor_script.rs | Batch complete inputs for Aster's Interactive parser/compiler, retain Module entry, and rebase Aster spans to the original snapshot |
@@ -14,14 +14,14 @@
| 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 |
| vm_sequence.rs | Shared validated call-sequence builder for event and lifecycle batches in the existing VM frame loop |
| source_layout.rs | Read-only bottom-up source measurement and top-down source arrangement, typed/slot validation and occurrence-specific geometry |
| 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 |
| source_paint.rs | Read-only source paint dispatch, shared VM budget, checked command conversion and versioned object dependencies |
| native_visual.rs | Managed native visual protocol queries with occurrence validation, value conversion and bounded results |
| object_list.rs | Traced indexed results shared by Mount queries and visual protocol results |
| layout.rs | Shared native geometry/paint functions, iterative measurement/arrangement, stable occurrence IDs, measurement cache and geometry budgets |
| document.rs | Published source/AST/UI snapshot, revisions, invalidation, frame validation and display commands |
| app.rs | Window events, redraw and system widget pointer/keyboard/IME routing |
| app.rs | Window events, logical viewport publication, redraw and system widget pointer/keyboard/IME routing |
| clipboard.rs | UTF-8 macOS clipboard adapter independent of Finder locale, with strict decoding and surfaced errors |
| system_ui/ | Host-only management widget library: stable IDs, interaction, theme and reusable control drawing |
| scene.rs | Page and developer-tools display-list composition |
+81 -3
View File
@@ -93,6 +93,7 @@ fn start_server_observed(
.spawn(move || {
let mut worker = Worker::default();
loop {
worker.refresh_viewport(&worker_namespace, &worker_notify);
match receiver.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(job) => {
let response = worker.run(&job, &worker_namespace, &worker_notify);
@@ -297,6 +298,18 @@ fn code_request(
"X-Tessera-Layout-Version",
&current.document.frame.layout_version.to_string(),
);
if let Some(viewport) = current.viewport {
add_header(
&mut response,
"X-Tessera-Viewport-Width",
&viewport.width.to_string(),
);
add_header(
&mut response,
"X-Tessera-Viewport-Height",
&viewport.height.to_string(),
);
}
add_header(
&mut response,
"X-Tessera-Source-Kind",
@@ -412,6 +425,55 @@ struct Worker {
next_session: u64,
}
impl Worker {
fn refresh_viewport(&mut self, namespace: &Arc<RwLock<NamespaceState>>, notify: &Notify) {
let (epoch, viewport) = match namespace.read() {
Ok(current) => (current.submission, current.viewport),
Err(_) => return,
};
let Some((owner, runtime)) = &mut self.repl else {
return;
};
if *owner != epoch {
self.repl = None;
return;
}
if runtime.is_faulted() {
return;
}
let id = runtime.id;
let result = catch_unwind(AssertUnwindSafe(|| runtime.resize(viewport)));
let mut current = match namespace.write() {
Ok(current) => current,
Err(_) => return,
};
if current.submission != epoch || current.document.frame.session != id {
return;
}
let document = match result {
Ok(Ok(Some(document))) => document,
Ok(Ok(None)) => return,
failure => {
let mut document = current.document.clone();
document.frame.fault = Some(match failure {
Ok(Err(error)) => format!("Viewport update failed: {error:#}")
.chars()
.take(1024)
.collect(),
_ => {
self.repl = None;
"Runtime discarded after an internal viewport failure".into()
}
});
document
}
};
let changed = matches!(current.commit(epoch, document), Ok(Some(_)));
drop(current);
if changed {
notify();
}
}
fn run(
&mut self,
job: &BuildRequest,
@@ -424,6 +486,7 @@ impl Worker {
if job.kind == RequestKind::Expression {
return build(job, namespace, notify);
}
self.refresh_viewport(namespace, notify);
let epoch = match namespace.read() {
Ok(current) => current.submission,
Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"),
@@ -485,7 +548,8 @@ impl Worker {
.is_none_or(|(current, _)| *current != epoch)
{
self.next_session += 1;
match endor_host::ReplSession::new(self.next_session) {
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)),
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
@@ -572,7 +636,21 @@ impl Worker {
};
let id = self.next_session;
let result = catch_unwind(AssertUnwindSafe(|| {
endor_host::ReplSession::load(&job.source, &job.namespace, job.submission, id)
let viewport = namespace
.read()
.map_err(|_| anyhow::anyhow!("namespace lock poisoned"))?
.viewport;
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)
}
}));
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock poisoned");
@@ -770,7 +848,7 @@ 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.
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. Constraints are currently finite/unconstrained page inputs; viewport propagation and source measurement caching 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.
+31 -1
View File
@@ -75,6 +75,26 @@ impl TesseraApp {
})
}
fn publish_viewport(&self) {
let Some(window) = &self.window else {
return;
};
let size = window.inner_size().to_logical::<f32>(window.scale_factor());
if size.width <= 0.0
|| size.height <= 0.0
|| !size.width.is_finite()
|| !size.height.is_finite()
{
return;
}
if let Ok(mut namespace) = self.namespace.write() {
namespace.viewport = Some(crate::layout::Size {
width: size.width.min(crate::ui_bridge::MAX_DIMENSION as f32),
height: size.height.min(crate::ui_bridge::MAX_DIMENSION as f32),
});
}
}
fn request_redraw(&self) {
if let Some(window) = &self.window {
window.request_redraw();
@@ -197,6 +217,14 @@ impl TesseraApp {
return None;
}
let current = self.namespace.read().ok()?;
if current.viewport.is_some_and(|size| {
self.pointer.x < 0.0
|| self.pointer.y < 0.0
|| self.pointer.x >= size.width
|| self.pointer.y >= size.height
}) {
return None;
}
let frame = &current.document.frame;
if frame.session == 0 {
return None;
@@ -359,6 +387,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
self.window = Some(window.clone());
self.context = Some(context);
self.surface = Some(surface);
self.publish_viewport();
window.request_redraw();
}
@@ -400,7 +429,8 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
window.request_redraw()
self.publish_viewport();
window.request_redraw();
}
WindowEvent::CursorMoved {
position: PhysicalPosition { x, y },
+2
View File
@@ -18,6 +18,7 @@ pub struct NamespaceState {
pub revision: u64,
pub submission: u64,
pub document: RenderDocument,
pub viewport: Option<crate::layout::Size>,
}
impl Default for NamespaceState {
@@ -27,6 +28,7 @@ impl Default for NamespaceState {
revision: 0,
submission: 0,
document: RenderDocument::default(),
viewport: None,
}
}
}
+258
View File
@@ -222,6 +222,16 @@ impl ReplSession {
namespace: &str,
submission: u64,
id: u64,
) -> Result<(Self, RenderDocument)> {
Self::load_with_viewport(source, namespace, submission, id, None)
}
pub fn load_with_viewport(
source: &str,
namespace: &str,
submission: u64,
id: u64,
viewport: Option<crate::layout::Size>,
) -> Result<(Self, RenderDocument)> {
if source.len() > MAX_SOURCE_BYTES {
bail!("source exceeds the 1 MiB limit");
@@ -234,6 +244,9 @@ impl ReplSession {
}
let compiled = compile_plan(&unit, &plan)?;
let mut runtime = Self::new(id)?;
runtime
.bridge
.set_viewport(&mut runtime.candidate.vm, viewport)?;
let globals = runtime.candidate.globals.expect("program globals");
let limits = if matches!(plan, SourcePlan::Script(_)) {
ExecutionLimits {
@@ -290,6 +303,14 @@ impl ReplSession {
Ok((runtime, document))
}
pub fn new_with_viewport(id: u64, viewport: Option<crate::layout::Size>) -> Result<Self> {
let mut runtime = Self::new(id)?;
runtime
.bridge
.set_viewport(&mut runtime.candidate.vm, viewport)?;
Ok(runtime)
}
pub fn new(id: u64) -> Result<Self> {
let mut candidate = Candidate::new()?;
let globals = candidate.vm.new_dict()?;
@@ -489,6 +510,25 @@ impl ReplSession {
result
}
pub fn resize(
&mut self,
viewport: Option<crate::layout::Size>,
) -> Result<Option<RenderDocument>> {
if self.faulted {
bail!("runtime stopped after a callback or rendering failure");
}
if !self.bridge.set_viewport(&mut self.candidate.vm, viewport)? {
return Ok(None);
}
let result = self.refresh_document();
if result.is_err() {
self.bridge.discard_pending(&mut self.candidate.vm)?;
}
self.bridge
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
result
}
pub fn is_faulted(&self) -> bool {
self.faulted
}
@@ -798,6 +838,224 @@ mod tests {
const EXAMPLE: &str = include_str!("../examples/hello.endor");
const PAGE: &str = include_str!("../examples/page.endor");
fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 {
let Value::Object(function) = session
.candidate
.vm
.get_attr(session.bridge.ui, "stats")
.unwrap()
else {
panic!("stats function")
};
let Value::Object(stats) = session.candidate.vm.call(function, &[]).unwrap() else {
panic!("stats")
};
let Value::Number(value) = session.candidate.vm.get_attr(stats, name).unwrap() else {
panic!("stat number")
};
session.candidate.vm.release(stats).unwrap();
value.as_i64().unwrap()
}
fn click_named(
session: &mut ReplSession,
document: &RenderDocument,
name: &str,
) -> RenderDocument {
let globals = session.candidate.globals.unwrap();
let Value::Object(block) = session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
else {
panic!("Block")
};
let mount = document
.frame
.mounts
.iter()
.find(|mount| mount.block_id == block.to_string())
.unwrap();
session
.dispatch(&crate::events::Input {
session: session.id,
layout_version: document.frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: None,
press_point: None,
})
.unwrap()
.document
.unwrap()
}
#[test]
fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes() {
let (mut session, initial) = ReplSession::load_with_viewport(
include_str!("../examples/layout-cache.endor"),
"app.main",
101,
1,
Some(crate::layout::Size {
width: 800.0,
height: 600.0,
}),
)
.unwrap();
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 2);
let painted = click_named(&mut session, &initial, "blue");
assert!(painted.frame.commands.iter().any(
|command| matches!(command,RenderCommand::RoundedRect {color,..} if color=="#526AC7")
));
assert_eq!(source_measure_stat(&mut session, "sourceMeasureHits"), 2);
assert_eq!(source_measure_stat(&mut session, "measured"), 0);
let resized = click_named(&mut session, &painted, "expand");
assert!(
resized
.frame
.mounts
.iter()
.any(|mount| mount.width == 260.0 && mount.height == 60.0)
);
assert_eq!(source_measure_stat(&mut session, "sourceMeasureHits"), 1);
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1);
}
#[test]
fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() {
let mut session = ReplSession::new_with_viewport(
104,
Some(crate::layout::Size {
width: 800.0,
height: 600.0,
}),
)
.unwrap();
assert!(
session
.execute("42", "app.main")
.unwrap()
.document
.is_none()
);
assert!(
session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0
}))
.unwrap()
.is_none()
);
let result = session.execute("ui.viewport().width", "app.main").unwrap();
assert_eq!(result.value, "360");
assert!(result.document.is_none());
}
#[test]
fn source_measure_cache_tracks_fields_of_an_existing_returned_result() {
let source = r##"class HeldRect: RectBlock {
result: MeasureResult
function measure(self: HeldRect, mount: Mount, constraints: Constraints): MeasureResult { return self.result }
}
result = MeasureResult(80,20)
rect = HeldRect(80,20,0,"#DDEEFF",result)
page = Page()
page.render(rect)"##;
let (mut session, initial) = ReplSession::load(source, "app.main", 103, 1).unwrap();
let id = initial
.frame
.mounts
.iter()
.find(|mount| mount.width == 80.0)
.unwrap()
.id;
let globals = session.candidate.globals.unwrap();
let Value::Object(result) = session
.candidate
.vm
.dict_get(globals, &Value::Text("result".into()))
.unwrap()
.unwrap()
else {
panic!("result")
};
session
.candidate
.vm
.set_attr(result, "width", Value::Number(140.into()))
.unwrap();
let next = session.refresh_document().unwrap().unwrap();
assert!(
next.frame
.mounts
.iter()
.any(|mount| mount.id == id && mount.width == 140.0)
);
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1);
assert!(session.refresh_document().unwrap().is_none());
}
#[test]
fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_source_input() {
let (mut session, initial) = ReplSession::load_with_viewport(
include_str!("../examples/layout-cache.endor"),
"app.main",
102,
1,
Some(crate::layout::Size {
width: 800.0,
height: 600.0,
}),
)
.unwrap();
let ids: Vec<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect();
let document = session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0,
}))
.unwrap()
.unwrap();
assert_eq!(
ids,
document
.frame
.mounts
.iter()
.map(|mount| mount.id)
.collect::<Vec<_>>()
);
assert!(
document
.frame
.mounts
.iter()
.any(|mount| mount.width == 312.0 && mount.height == 24.0)
);
assert_eq!(session.inputs, 0);
assert!(
session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0
}))
.unwrap()
.is_none()
);
assert!(
session
.resize(Some(crate::layout::Size {
width: f32::NAN,
height: 240.0
}))
.is_err()
);
assert!(!session.is_faulted());
}
const SOURCE_LAYOUT: &str = include_str!("../examples/source-layout.endor");
#[test]
+49 -5
View File
@@ -38,6 +38,7 @@ pub struct LayoutRuntime {
pub version: u64,
pub measured: usize,
pub arranged: usize,
pub viewport: Option<Size>,
}
struct Item<'a> {
@@ -45,6 +46,7 @@ struct Item<'a> {
parent: Option<usize>,
children: Vec<usize>,
id: u64,
constraints: [f32; 4],
}
fn bounded(value: f32) -> Result<f32> {
@@ -54,6 +56,37 @@ fn bounded(value: f32) -> Result<f32> {
Ok(value)
}
pub fn root_constraints(viewport: Option<Size>) -> [f32; 4] {
let size = viewport.unwrap_or(Size {
width: MAX_DIMENSION as f32,
height: MAX_DIMENSION as f32,
});
[0.0, 0.0, size.width, size.height]
}
pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
match container(node) {
Some((true, padding, _)) => [
0.0,
0.0,
MAX_DIMENSION as f32,
(parent[3] - padding * 2.0).max(0.0),
],
Some((false, padding, _)) => [
0.0,
0.0,
(parent[2] - padding * 2.0).max(0.0),
MAX_DIMENSION as f32,
],
None => parent,
}
}
pub fn constrained(size: Size, constraints: [f32; 4]) -> Size {
Size {
width: size.width.clamp(constraints[0], constraints[2]),
height: size.height.clamp(constraints[1], constraints[3]),
}
}
fn container(node: &UiNode) -> Option<(bool, f32, f32)> {
match &node.kind {
UiKind::Row { padding, gap, .. } => Some((true, *padding, *gap)),
@@ -227,9 +260,9 @@ impl LayoutRuntime {
self.measured = 0;
self.arranged = 0;
let mut items = Vec::<Item<'_>>::new();
let mut work = vec![(root, None::<usize>, 0)];
let mut work = vec![(root, None::<usize>, 0, root_constraints(self.viewport))];
let mut used = HashSet::new();
while let Some((node, parent, slot)) = work.pop() {
while let Some((node, parent, slot, constraints)) = work.pop() {
if items.len() >= MAX_LAYOUT_VISITS {
bail!("expanded UI layout exceeds 10000 nodes");
}
@@ -263,16 +296,27 @@ impl LayoutRuntime {
parent,
children: vec![],
id,
constraints,
});
for (child, slot) in node.children().iter().zip(&node.slots).rev() {
work.push((child.as_ref(), Some(index), *slot));
work.push((
child.as_ref(),
Some(index),
*slot,
child_constraints(node, constraints),
));
}
}
let mut sizes = vec![Size::default(); items.len()];
if !structural {
for (index, item) in items.iter().enumerate().rev() {
sizes[index] = self
.measure_node(item.node, item.children.iter().map(|i| sizes[*i]).collect())?;
sizes[index] = constrained(
self.measure_node(
item.node,
item.children.iter().map(|i| sizes[*i]).collect(),
)?,
item.constraints,
);
}
}
let mut points = vec![(0.0_f32, 0.0_f32); items.len()];
+183 -18
View File
@@ -14,13 +14,55 @@ use endor_core::{
NativeContext, NativeResult, ObjectRef, Opcode, Value, Vm, VmError, WeakObjectRef,
};
use std::{
collections::{BTreeSet, HashMap},
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
sync::{Arc, Mutex},
};
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
fn desktop_type_registry(cx: &NativeContext<'_>, desktop: ObjectRef) -> Result<ObjectRef, VmError> {
match cx.store().get_attr(desktop, "types")? {
Value::Object(object) => Ok(object),
_ => Err(invalid("missing type registry")),
}
}
#[derive(Debug, Clone)]
struct Measurement {
owner: ObjectRef,
method: ObjectRef,
children: Vec<Size>,
constraints: [f32; 4],
size: Size,
reads: BTreeMap<ObjectRef, u64>,
}
/// Borrowed identities and versions only: this cache adds no managed roots.
#[derive(Debug, Default, Clone)]
pub struct Cache {
entries: HashMap<WeakObjectRef, Measurement>,
pub hits: usize,
pub misses: usize,
}
impl Cache {
pub fn invalidate(&mut self, owners: &HashSet<ObjectRef>) {
self.entries
.retain(|_, entry| !owners.contains(&entry.owner));
}
pub fn clear(&mut self) {
self.entries.clear();
self.hits = 0;
self.misses = 0;
}
}
#[derive(Debug)]
struct Pending {
token: usize,
owner: ObjectRef,
method: ObjectRef,
children: Vec<Size>,
constraints: [f32; 4],
key: WeakObjectRef,
}
#[derive(Debug)]
struct Plan {
nodes: Vec<Arc<UiNode>>,
@@ -29,6 +71,9 @@ struct Plan {
bounds: Vec<Option<[f32; 4]>>,
runtime: LayoutRuntime,
custom: bool,
cache: Cache,
pending: Vec<Option<Pending>>,
constraints: Vec<[f32; 4]>,
}
type Shared = Arc<Mutex<Plan>>;
#[derive(Debug, Clone, Copy)]
@@ -92,9 +137,41 @@ impl NativeCallable for Step {
plan.custom = true;
}
if matches!(self.phase, Phase::Measure) {
plan.runtime.measured += 1;
let key = cx.store().downgrade(self.mount)?;
let constraints = plan.constraints[self.index];
let cached = plan
.cache
.entries
.get(&key)
.filter(|entry| {
entry.owner == mount.block
&& entry.method == method
&& entry.children == children
&& entry.constraints == constraints
&& entry.reads.iter().all(|(object, version)| {
cx.store().change_version(*object).ok() == Some(*version)
})
})
.map(|entry| entry.size);
if let Some(size) = cached {
plan.cache.hits += 1;
Some(size)
} else {
plan.cache.misses += 1;
plan.runtime.measured += 1;
plan.pending[self.index] = Some(Pending {
token: 0,
owner: mount.block,
method,
children: children.clone(),
constraints,
key,
});
None
}
} else {
None
}
None
};
if matches!(self.phase, Phase::Arrange) {
plan.runtime.arranged += 1;
@@ -119,17 +196,24 @@ impl NativeCallable for Step {
)?)));
}
let input = match self.phase {
Phase::Measure => record(
cx,
self.desktop,
"Constraints",
vec![
("minWidth", value(0.0)),
("minHeight", value(0.0)),
("maxWidth", value(MAX_DIMENSION as f32)),
("maxHeight", value(MAX_DIMENSION as f32)),
],
)?,
Phase::Measure => {
let constraints = self
.plan
.lock()
.map_err(|_| invalid("layout plan poisoned"))?
.constraints[self.index];
record(
cx,
self.desktop,
"Constraints",
vec![
("minWidth", value(constraints[0])),
("minHeight", value(constraints[1])),
("maxWidth", value(constraints[2])),
("maxHeight", value(constraints[3])),
],
)?
}
Phase::Arrange => {
let [x, y, width, height] =
bounds.ok_or(invalid("arrangement has no allocated bounds"))?;
@@ -146,6 +230,16 @@ impl NativeCallable for Step {
)?
}
};
if matches!(self.phase, Phase::Measure) {
let token = cx.store().begin_read_tracking()?;
self.plan
.lock()
.map_err(|_| invalid("layout plan poisoned"))?
.pending[self.index]
.as_mut()
.ok_or(invalid("missing measurement tracking state"))?
.token = token;
}
Ok(NativeResult::Invoke {
function: method,
arguments: vec![
@@ -165,6 +259,15 @@ struct Collect {
}
impl NativeCallable for Collect {
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let pending = if matches!(self.phase, Phase::Measure) {
self.plan
.lock()
.map_err(|_| invalid("layout plan poisoned"))?
.pending[self.index]
.take()
} else {
None
};
let [Value::Object(result)] = args else {
return Err(invalid("layout must return a managed result"));
};
@@ -184,10 +287,54 @@ impl NativeCallable for Collect {
.map_err(|_| invalid("layout plan poisoned"))?;
match self.phase {
Phase::Measure => {
let size = Size {
width: number(cx, *result, "width", false)?,
height: number(cx, *result, "height", false)?,
};
let size = layout::constrained(
Size {
width: number(cx, *result, "width", false)?,
height: number(cx, *result, "height", false)?,
},
plan.constraints[self.index],
);
// Decoding is part of the subcall dependency region: a source
// method may return an existing result whose fields can change.
let tracked = pending
.map(|pending| {
cx.store()
.end_read_tracking(pending.token)
.map(|reads| (pending, reads))
})
.transpose()?;
if let Some((pending, reads)) = tracked {
let mut versions = BTreeMap::new();
let mut volatile = false;
let registry = desktop_type_registry(cx, self.desktop)?;
for object in reads {
if object == self.desktop || object == registry {
continue;
}
// A Mount's layoutVersion/geometry may be observed by
// source. Object-granular tracking cannot prove those
// accesses stable, so conservatively bypass its cache.
if cx.store().object(object)?.payload().as_any().is::<Mount>() {
volatile = true;
}
versions.insert(object, cx.store().change_version(object)?);
}
if volatile {
plan.cache.entries.remove(&pending.key);
} else {
plan.cache.entries.insert(
pending.key,
Measurement {
owner: pending.owner,
method: pending.method,
children: pending.children,
constraints: pending.constraints,
size,
reads: versions,
},
);
}
}
plan.sizes[self.index] = size;
if self.index == 0 {
plan.bounds[0] = Some([0.0, 0.0, size.width, size.height]);
@@ -327,7 +474,12 @@ pub fn render(
root: Arc<UiNode>,
frame: &mut Frame,
runtime: &mut LayoutRuntime,
cache: &mut Cache,
) -> Result<BTreeSet<ObjectRef>> {
cache.hits = 0;
cache.misses = 0;
let live: HashSet<_> = mounts.iter().copied().collect();
cache.entries.retain(|mount, _| live.contains(mount));
// Preserve the existing native measurement cache and avoid VM/result
// allocation for trees without custom layout methods. Contexts already exist.
let block_type = desktop_type(vm.object_store(), "Block", desktop)?;
@@ -353,6 +505,7 @@ pub fn render(
custom |= result?;
}
if !custom {
cache.clear();
let mut native = runtime.frame(&root)?;
// Only the structural pass consumes a published version.
native.layout_version = frame.layout_version;
@@ -382,6 +535,14 @@ pub fn render(
children[indices[&parent]].push(index);
}
}
let mut constraints = vec![layout::root_constraints(runtime.viewport); nodes.len()];
for index in 0..nodes.len() {
let next = layout::child_constraints(&nodes[index], constraints[index]);
for child in &children[index] {
constraints[*child] = next;
}
}
let count = nodes.len();
let plan = Arc::new(Mutex::new(Plan {
sizes: vec![Size::default(); nodes.len()],
bounds: vec![None; nodes.len()],
@@ -389,6 +550,9 @@ pub fn render(
children,
runtime: runtime.clone(),
custom: false,
cache: cache.clone(),
pending: (0..count).map(|_| None).collect(),
constraints,
}));
let prepare = vm.allocate(ContextNativeFunction::new(
"prepare source layout",
@@ -429,6 +593,7 @@ pub fn render(
}
}
*runtime = plan.runtime.clone();
*cache = plan.cache.clone();
if !plan.custom {
return Ok(BTreeSet::new());
}
+88 -3
View File
@@ -181,6 +181,7 @@ struct Session {
force_frame: bool,
paint_reads: std::collections::BTreeMap<ObjectRef, u64>,
layout_reads: std::collections::BTreeMap<ObjectRef, u64>,
measure_cache: crate::source_layout::Cache,
snapshot_visited: usize,
snapshot_reused: usize,
}
@@ -193,6 +194,7 @@ enum Operation {
OpenValue,
Mounts,
Stats,
Viewport,
State,
StateSet,
Increment,
@@ -494,7 +496,7 @@ impl NativeCallable for Function {
use Operation::*;
let (minimum, maximum) = match self.operation {
IsInstance | IsSubtype => (2, 2),
Stats | Mounts => (0, 0),
Stats | Viewport | Mounts => (0, 0),
State | StateSet => (1, 1),
Increment => (0, 0),
TextOf => (2, 4),
@@ -555,6 +557,7 @@ impl NativeCallable for Function {
}
}
if reason != "paint" {
session.measure_cache.invalidate(&affected);
for block in &affected {
session.layout.invalidate(&block.to_string());
}
@@ -605,7 +608,32 @@ impl NativeCallable for Function {
)?;
return Ok(NativeResult::Return(Value::Object(set)));
}
if matches!(self.operation, Viewport) {
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
let Value::Object(input) = cx.store().get_attr(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")?;
return Ok(NativeResult::Return(Value::Object(
crate::native_visual::record(
cx,
desktop,
"MeasureResult",
vec![("width", width), ("height", height)],
)?,
)));
}
if matches!(self.operation, Stats) {
if cx.store().is_read_only() {
return Err(invalid(
"runtime statistics are unavailable during read-only rendering",
));
}
let session = self
.session
.lock()
@@ -632,6 +660,8 @@ impl NativeCallable for Function {
("snapshotVisited", session.snapshot_visited as i64),
("snapshotReused", session.snapshot_reused as i64),
("measured", session.layout.measured as i64),
("sourceMeasureHits", session.measure_cache.hits as i64),
("sourceMeasureMisses", session.measure_cache.misses as i64),
("arranged", session.layout.arranged as i64),
] {
cx.set_attr(stats, name, Value::Number(value.into()))?;
@@ -1020,6 +1050,41 @@ pub struct UiBridge {
}
impl UiBridge {
pub fn set_viewport(&self, vm: &mut Vm, viewport: Option<crate::layout::Size>) -> Result<bool> {
if let Some(size) = viewport
&& (!size.width.is_finite()
|| !size.height.is_finite()
|| !(0.0..=MAX_DIMENSION as f32).contains(&size.width)
|| !(0.0..=MAX_DIMENSION as f32).contains(&size.height))
{
bail!("invalid window viewport");
}
let mut state = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
if state.layout.viewport == viewport {
return Ok(false);
}
if let Some(size) = viewport {
let input = vm.new_object()?;
let result = (|| {
vm.set_attr(input, "width", Value::Number((size.width as f64).into()))?;
vm.set_attr(input, "height", Value::Number((size.height as f64).into()))?;
vm.set_attr(state.desktop, "viewport", Value::Object(input))
})();
vm.release(input)?;
result?;
} else {
vm.set_attr(state.desktop, "viewport", Value::Nil)?;
}
state.layout.viewport = viewport;
// A fresh Console domain can own window inputs without replacing the
// preserved displayed page until it actually opens/renders something.
state.force_frame |= state.active.is_some() || state.pending.flatten().is_some();
Ok(true)
}
pub fn install_globals(&self, vm: &mut Vm, globals: ObjectRef) -> Result<()> {
vm.dict_set(globals, Value::Text("ui".into()), Value::Object(self.ui))?;
for name in TYPE_NAMES.iter().chain(QUERY_NAMES) {
@@ -1047,7 +1112,7 @@ impl UiBridge {
input
.validate()
.map_err(|message| anyhow::anyhow!(message))?;
let (weak, desktop, candidates) = {
let (weak, desktop, candidates, viewport) = {
let state = self
.session
.lock()
@@ -1063,9 +1128,17 @@ impl UiBridge {
.ok_or_else(|| anyhow::anyhow!("Mount is no longer active"))?,
state.desktop,
state.mounts.ordered(),
state.layout.viewport,
)
};
let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) {
if viewport.is_some_and(|size| {
[press, release]
.iter()
.any(|point| point[0] >= size.width || point[1] >= size.height)
}) {
return Ok(());
}
let Some(mount) = crate::source_hit::resolve(vm, desktop, candidates, press, release)?
else {
return Ok(());
@@ -1273,6 +1346,11 @@ impl UiBridge {
root: Option<Arc<UiNode>>,
) -> Result<crate::document::Frame> {
let Some(root) = root else {
self.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
.measure_cache
.clear();
self.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
@@ -1282,13 +1360,14 @@ impl UiBridge {
self.commit_frame(vm, None, &frame)?;
return Ok(frame);
};
let (mut layout, desktop, page, nodes) = {
let (mut layout, mut cache, desktop, page, nodes) = {
let state = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
(
state.layout.clone(),
state.measure_cache.clone(),
state.desktop,
state.pending.unwrap_or(state.active),
state.nodes.clone(),
@@ -1313,6 +1392,7 @@ impl UiBridge {
root.clone(),
&mut frame,
&mut layout,
&mut cache,
)?;
// Capture versions before lifecycle hooks can change a dependency.
let reads = reads
@@ -1332,6 +1412,7 @@ impl UiBridge {
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
state.layout = layout;
state.measure_cache = cache;
state.layout_reads = reads;
}
self.commit_frame(vm, Some(root), &frame)?;
@@ -1372,6 +1453,7 @@ impl UiBridge {
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let desktop = vm.new_object()?;
roots.push(desktop);
vm.set_attr(desktop, "viewport", Value::Nil)?;
let tabs = vm.new_dict()?;
roots.push(tabs);
vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
@@ -1391,6 +1473,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
force_frame: false,
paint_reads: std::collections::BTreeMap::new(),
layout_reads: std::collections::BTreeMap::new(),
measure_cache: crate::source_layout::Cache::default(),
snapshot_visited: 0,
snapshot_reused: 0,
}));
@@ -1409,6 +1492,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("state", Operation::State),
("textOf", Operation::TextOf),
("stats", Operation::Stats),
("viewport", Operation::Viewport),
] {
let function = vm.allocate(ContextNativeFunction::new(
name,
@@ -1560,6 +1644,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
"state",
"textOf",
"stats",
"viewport",
"isInstance",
"isSubtype",
] {
+1 -1