diff --git a/README.md b/README.md index cebd84a..04ae6b8 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,11 @@ 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 and additional native windows remain future work. Rendering state now lives in a per-window Presentation registry: layout, Mounts, focus, tab identities and caches remain local, while the weak visual-object registry and VM changes belong to the common runtime. Every view receives the same drained VM change batch. Frame and native-event window IDs are checked alongside session and layout versions; existing event clients default to window 1, exposed by X-Tessera-Window. This prepares independent views but does not yet enable creating another native window. +`Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Dragging a Tab away from its strip invokes that same Page method. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor). + +`window.close()` and a secondary native close button call the same managed method. Closing first removes Page ownership, then commits unmounts and removes that context from the runtime registry; the native resource disappears after successful publication. Retained Pages can be opened in another live Window; otherwise Aster reclaims them normally. Window resource objects may remain referenced after closing, but reject resource operations; id/pages remain queryable. A retained leaf Mount uses a weak context link and cannot keep a closed window alive through its query methods. The primary shell still defines the application's lifetime: closing its OS window exits Tessera. Dragging onto another existing window, placement at the release point and cleanup actions inside a faulted VM remain follow-up work. + +Each window has its own Presentation, layout/Mount/focus state, caches and native viewport. A single VM change batch fans out to all views, all affected frames settle before publication, and shared business references are never copied. Frames and native inputs carry window/session/layout identities; stale requests are rejected. Existing event clients default to window 1, exposed by X-Tessera-Window. Secondary native resize feeds the same worker and updates only its window constraints. Console reset preserves all old pictures with input disabled; replacement sessions discard the old native resources before projecting new resources with the same numeric IDs. Open Pages appear in the native shell tab bar. Set `page.title` to label a tab; omitted titles use their open-order number. Clicking a tab or its close control calls the actual Page's `activate()` or `close()` method in the existing VM; source overrides participate. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, and Cmd/Ctrl+W closes the active page when the page has keyboard focus. Overflow arrows expose additional pages. Closed Pages follow managed references and GC; a retained Page can be reopened. The tab projection contains opaque session-local IDs and text, with weak reverse lookup in the runtime, and adds no Page roots. Requests carry the published layout version; an old tab event is rejected. Switching creates new presentation Mounts while preserving the business objects. See [tabs.endor](examples/tabs.endor). @@ -210,7 +214,7 @@ The bar occupies 36 logical pixels when at least one Page is open. Page constrai `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. +Native creation and 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. diff --git a/examples/README.md b/examples/README.md index 6aac4b2..d471a61 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,7 @@ | File | Purpose | | --- | --- | +| [windows.endor](windows.endor) | Two real native windows in one Aster VM, live shared counter state and explicit Page ownership. | | [tabs.endor](tabs.endor) | Three real Page tabs, shared counter state, source titles, native switching/closing and reopening through code. | | [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. | diff --git a/examples/windows.endor b/examples/windows.endor new file mode 100644 index 0000000..620e2e4 --- /dev/null +++ b/examples/windows.endor @@ -0,0 +1,18 @@ +// Both native windows use one Aster VM and one shared business object. +count = ui.state(0) +leftButton = Button("共享计数 +1") +leftConnection = leftButton.on("click",count.increment) +left = Page() +left.title = "主窗口页面" +left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton)) + +other = Window() +rightButton = Button("另一个窗口 +1") +rightConnection = rightButton.on("click",count.increment) +right = Page() +right.title = "独立窗口页面" +right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton)) +right.open(other) +ui.window().setTitle("Endor Main") +other.setTitle("Endor Shared") +other.resize(960,640) diff --git a/src/README.md b/src/README.md index 9a6ba18..fa2bc3b 100644 --- a/src/README.md +++ b/src/README.md @@ -15,7 +15,7 @@ | event_dispatch.rs | Managed Event, weak Subscription tokens, Block-owned connections and occurrence-specific capture/target/bubble through a budgeted VM driver | | presentation.rs | Per-window layout, Mount/focus/tab state and read dependencies; VM changes fan out before any view consumes work | | tabs.rs | Immutable tab chrome, shared draw/hit geometry, overflow navigation and opaque published Page identities | -| window.rs | Managed native Window resource, ordered Page roots, read-only guards and coalesced scalar GUI projections | +| window.rs | Native Window construction/close, context ownership, ordered Page roots, read-only guards and coalesced 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 | diff --git a/src/api.rs b/src/api.rs index fbfda43..a080f3d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -431,8 +431,14 @@ struct Worker { } impl Worker { fn refresh_viewport(&mut self, namespace: &Arc>, notify: &Notify) { - let (epoch, viewport) = match namespace.read() { - Ok(current) => (current.submission, current.viewport), + let (epoch, viewports) = match namespace.read() { + Ok(current) => { + let mut viewports = current.window_viewports.clone(); + if let Some(viewport) = current.viewport { + viewports.insert(crate::window::MAIN_WINDOW, viewport); + } + (current.submission, viewports) + } Err(_) => return, }; let Some((owner, runtime)) = &mut self.repl else { @@ -447,7 +453,7 @@ impl Worker { } let id = runtime.id; let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { - let document = runtime.resize(viewport)?; + let document = runtime.resize_windows(&viewports)?; Ok((document, runtime.take_window_commands()?)) })); let mut current = match namespace.write() { @@ -485,6 +491,9 @@ impl Worker { "Runtime discarded after an internal viewport failure".into() } }); + for view in document.windows.values_mut() { + view.frame.fault = document.frame.fault.clone(); + } (document, None) } }; @@ -523,6 +532,10 @@ impl Worker { if let Ok(mut current) = namespace.write() { current.document.frame.session = 0; current.document.frame.focus = None; + for document in current.document.windows.values_mut() { + document.frame.session = 0; + document.frame.focus = None; + } current.runtime_session = 0; current.window_commands.clear(); } @@ -539,7 +552,7 @@ impl Worker { }; if !matches!( input.kind.as_str(), - "click" | "focus" | "tab-activate" | "tab-close" + "click" | "focus" | "tab-activate" | "tab-close" | "tab-detach" | "window-close" ) { return error_response(StatusCode(400), "unsupported event kind"); } @@ -550,12 +563,24 @@ impl Worker { Ok(current) => current, Err(_) => return error_response(StatusCode(500), "namespace lock poisoned"), }; - let frame = ¤t.document.frame; + let frame = if input.window == crate::window::MAIN_WINDOW { + ¤t.document.frame + } else { + let Some(document) = current.document.windows.get(&input.window) else { + return error_response(StatusCode(409), "window is no longer active"); + }; + &document.frame + }; if frame.window != input.window || frame.session != input.session - || frame.layout_version != input.layout_version + || (input.kind != "window-close" && frame.layout_version != input.layout_version) || frame.fault.is_some() - || if matches!(input.kind.as_str(), "tab-activate" | "tab-close") { + || if input.kind == "window-close" { + false + } else if matches!( + input.kind.as_str(), + "tab-activate" | "tab-close" | "tab-detach" + ) { !frame.tabs.iter().any(|tab| tab.id == input.mount) } else { !frame @@ -564,7 +589,9 @@ impl Worker { .any(|mount| mount.id == input.mount && mount.interactive) } || self.repl.as_ref().is_none_or(|(context, runtime)| { - *context != epoch || runtime.id != input.session + *context != epoch + || runtime.id != input.session + || (input.kind != "window-close" && !runtime.accepts_window(input.window)) }) { return error_response( @@ -642,7 +669,11 @@ impl Worker { Ok(Err(error)) => { if event.is_some() || faulted { let mut document = current.document.clone(); - document.frame.fault = Some(format!("{error:#}").chars().take(1024).collect()); + let fault: String = format!("{error:#}").chars().take(1024).collect(); + document.frame.fault = Some(fault.clone()); + for view in document.windows.values_mut() { + view.frame.fault = Some(fault.clone()); + } let _ = current.commit(epoch, document); drop(current); notify(); @@ -653,6 +684,9 @@ impl Worker { let mut document = current.document.clone(); document.frame.fault = Some("Runtime discarded after an internal execution failure".into()); + for view in document.windows.values_mut() { + view.frame.fault = document.frame.fault.clone(); + } current.runtime_session = 0; current.window_commands.clear(); let _ = current.commit(epoch, document); @@ -901,13 +935,13 @@ 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. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Additional windows, Window() construction and detach/move remain pending. +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 their actual 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. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Window() now creates additional native windows in the same VM, up to 16 open resources. Page.open(window)/moveToWindow(window) preserve Page and business identity while replacing Mounts; detachToWindow() creates and returns a new Window. Dragging a tab away from its strip invokes the same method. Window.close()/secondary OS close remove Page roots, unmount, and retire the resource context; retained Pages can reopen in another live window, and closed Window resources reject viewport/resize/title/focus operations. Closing the primary OS shell exits the application. Secondary viewport inputs and frame publication use the same worker. Existing-window drag/drop, release-point placement and faulted-VM cleanup 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. -Native input POST /events is a transport envelope, not Endor source. Each frame and input carries an opaque runtime window identity; omitted input window defaults to 1 for existing clients. GET /code exposes X-Tessera-Window. A window/session/layout identity mismatch is rejected before invoking code. Tab actions use kind tab-activate/tab-close and the mount wire field carries the opaque tab ID from frame.tabs; point/press_point must be omitted. All actions validate session and layout_version before dispatch. The window +Native input POST /events is a transport envelope, not Endor source. Each frame and input carries an opaque runtime window identity; omitted input window defaults to 1 for existing clients. GET /code exposes X-Tessera-Window. A window/session/layout identity mismatch is rejected before invoking code. Tab actions use kind tab-activate/tab-close/tab-detach and the mount wire field carries the opaque tab ID from frame.tabs; point/press_point must be omitted. All spatial/tab actions validate session and layout_version before dispatch. Resource window-close uses mount=0 and no points; it validates the live window/session rather than its old layout version. The window sends current session, layout_version and Mount identity; stale positions fail without calling a callback. Pointer clicks also include point and press_point [x,y] in logical page coordinates. Both targets are resolved through read-only hitTest, with fallback behind a rejected candidate; a click is dispatched only when both resolve to the same Mount. Keyboard/code clicks omit coordinates and remain semantic activation. GET /code also exposes X-Tessera-Session and X-Tessera-Layout-Version headers for transport clients. Native mouse, Tab, Enter and Space delegate to the same managed click/focus methods used by code. Events appear in Network, not Console source history. diff --git a/src/app.rs b/src/app.rs index 35bf700..540f206 100644 --- a/src/app.rs +++ b/src/app.rs @@ -30,6 +30,10 @@ pub enum AppEvent { } pub struct TesseraApp { + resource: u64, + native_session: u64, + children: std::collections::BTreeMap, + proxy: EventLoopProxy, window: Option>, context: Option>>, surface: Option, Arc>>, @@ -42,6 +46,7 @@ pub struct TesseraApp { event_client: crate::events::Client, guest_pressed: Option, tab_pressed: Option, + tab_drag_origin: Option, guest_focus: Option, guest_key: Option<(NamedKey, crate::events::Input)>, guest_keyboard: bool, @@ -55,6 +60,10 @@ impl TesseraApp { ) -> anyhow::Result { let event_client = crate::events::Client::new(proxy.clone())?; Ok(Self { + resource: crate::window::MAIN_WINDOW, + native_session: 0, + children: std::collections::BTreeMap::new(), + proxy: proxy.clone(), window: None, context: None, surface: None, @@ -71,36 +80,114 @@ impl TesseraApp { event_client, guest_pressed: None, tab_pressed: None, + tab_drag_origin: None, guest_focus: None, guest_key: None, guest_keyboard: true, }) } - fn apply_window_commands(&self) { - let Some(window) = &self.window else { + fn reconcile_windows(&mut self, event_loop: &ActiveEventLoop) { + if self.resource != crate::window::MAIN_WINDOW { + return; + } + let Ok(namespace) = self.namespace.read() else { return; }; + let session = namespace.runtime_session; + let revision = namespace.revision; + let submission = namespace.submission; + let documents = namespace.document.windows.clone(); + drop(namespace); + if session != self.native_session { + // Console reset preserves the old projection with input disabled. + // A real replacement changes frame sessions and drops old OS views. + let replaces = documents + .values() + .any(|document| document.frame.session == session && session != 0); + if replaces || (session != 0 && documents.is_empty()) { + self.children.clear(); + } + self.native_session = session; + } + self.children.retain(|id, child| { + documents.get(id).is_some_and(|document| { + document.frame.session == 0 || child.native_session == document.frame.session + }) + }); + for (id, document) in documents { + if let Some(child) = self.children.get_mut(&id) { + if let Ok(mut namespace) = child.namespace.write() { + namespace.document = document; + namespace.runtime_session = session; + namespace.revision = revision; + namespace.submission = submission; + } + child.document_updated(event_loop); + } else { + let namespace = Arc::new(RwLock::new(NamespaceState { + document, + runtime_session: session, + revision, + submission, + ..NamespaceState::default() + })); + match Self::new(namespace, self.activity.clone(), self.proxy.clone()) { + Ok(mut child) => { + child.resource = id; + child.native_session = session; + child.resumed(event_loop); + child.document_updated(event_loop); + self.children.insert(id, child); + } + Err(error) => { + self.developer_tools.input_error = format!("Window {id}: {error}"); + self.developer_tools.open = true; + } + } + } + } + } + + fn apply_window_commands(&mut self, event_loop: &ActiveEventLoop) { let Ok(mut namespace) = self.namespace.write() else { return; }; + let session = namespace.runtime_session; let commands = std::mem::take(&mut namespace.window_commands); + drop(namespace); for envelope in commands { - if envelope.session == 0 - || envelope.session != namespace.runtime_session - || envelope.command.window != crate::window::MAIN_WINDOW - { + if envelope.session == 0 || envelope.session != session { continue; } + let id = envelope.command.window; + if matches!(envelope.command.action, crate::window::Action::Close) { + if id == crate::window::MAIN_WINDOW { + event_loop.exit(); + } else { + self.children.remove(&id); + } + continue; + } + let target = if id == self.resource { + self.window.as_ref() + } else { + self.children + .get(&id) + .and_then(|child| child.window.as_ref()) + }; + let Some(window) = target else { + 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(), + crate::window::Action::Close => {} } } - drop(namespace); self.publish_viewport(); } @@ -117,10 +204,24 @@ impl TesseraApp { return; } if let Ok(mut namespace) = self.namespace.write() { - namespace.viewport = Some(crate::layout::Size { + let size = 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), - }); + }; + namespace.viewport = Some(size); + namespace.window_viewports.insert(self.resource, size); + if self.resource == crate::window::MAIN_WINDOW { + namespace.window_viewports.retain(|id, _| { + *id == crate::window::MAIN_WINDOW || self.children.contains_key(id) + }); + for (id, child) in &self.children { + if let Ok(local) = child.namespace.read() + && let Some(size) = local.viewport + { + namespace.window_viewports.insert(*id, size); + } + } + } } } @@ -138,8 +239,9 @@ impl TesseraApp { self.request_redraw(); } - fn document_updated(&mut self) { - self.apply_window_commands(); + fn document_updated(&mut self, event_loop: &ActiveEventLoop) { + self.reconcile_windows(event_loop); + self.apply_window_commands(event_loop); if let Ok(namespace) = self.namespace.read() { self.developer_tools.document_updated(&namespace.document); let frame = &namespace.document.frame; @@ -148,7 +250,7 @@ impl TesseraApp { .focus .filter(|_| frame.fault.is_none()) .map(|mount| crate::events::Input { - window: crate::window::MAIN_WINDOW, + window: self.resource, session: frame.session, layout_version: frame.layout_version, mount, @@ -276,7 +378,7 @@ impl TesseraApp { let point = Point::new(self.pointer.x, self.pointer.y - offset); let mount = frame.hit_test(point)?; Some(crate::events::Input { - window: crate::window::MAIN_WINDOW, + window: self.resource, session: frame.session, layout_version: frame.layout_version, mount: mount.id, @@ -301,7 +403,7 @@ impl TesseraApp { (active + 1) % frame.tabs.len() }; Some(crate::events::Input { - window: crate::window::MAIN_WINDOW, + window: self.resource, session: frame.session, layout_version: frame.layout_version, mount: frame.tabs[index].id, @@ -314,6 +416,7 @@ impl TesseraApp { self.guest_pressed = None; self.guest_key = None; self.tab_pressed = None; + self.tab_drag_origin = None; self.send_guest(input); true } else { @@ -334,7 +437,7 @@ impl TesseraApp { let width = window.inner_size().width as f32 / window.scale_factor() as f32; let (id, close) = crate::tabs::hit(&frame.tabs, width, self.pointer)?; Some(crate::events::Input { - window: crate::window::MAIN_WINDOW, + window: self.resource, session: frame.session, layout_version: frame.layout_version, mount: id, @@ -382,7 +485,7 @@ impl TesseraApp { current.map(|i| (i + 1) % targets.len()).unwrap_or(0) }; Some(crate::events::Input { - window: crate::window::MAIN_WINDOW, + window: self.resource, session: frame.session, layout_version: frame.layout_version, mount: targets[index].id, @@ -476,8 +579,16 @@ impl ApplicationHandler for TesseraApp { } let attributes = WindowAttributes::default() - .with_title("Tessera") - .with_inner_size(LogicalSize::new(1_280.0, 800.0)) + .with_title(if self.resource == crate::window::MAIN_WINDOW { + "Tessera".into() + } else { + format!("Endor Window {}", self.resource) + }) + .with_inner_size(if self.resource == crate::window::MAIN_WINDOW { + LogicalSize::new(1_280.0, 800.0) + } else { + LogicalSize::new(960.0, 640.0) + }) .with_min_inner_size(LogicalSize::new(720.0, 480.0)) .with_max_inner_size(LogicalSize::new( crate::window::MAX_WINDOW_DIMENSION, @@ -496,12 +607,12 @@ impl ApplicationHandler for TesseraApp { self.window = Some(window.clone()); self.context = Some(context); self.surface = Some(surface); - self.apply_window_commands(); + self.apply_window_commands(event_loop); self.publish_viewport(); window.request_redraw(); } - fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: AppEvent) { + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: AppEvent) { match event { AppEvent::RuntimeFailed { message } => { self.developer_tools.input_error = message; @@ -510,7 +621,7 @@ impl ApplicationHandler for TesseraApp { self.guest_key = None; self.request_redraw(); } - AppEvent::DocumentUpdated => self.document_updated(), + AppEvent::DocumentUpdated => self.document_updated(event_loop), AppEvent::ConsoleFailed { message, source } => { self.developer_tools.input_error = format!("Request failed: {message}"); if self.developer_tools.input.is_empty() { @@ -533,11 +644,41 @@ impl ApplicationHandler for TesseraApp { return; }; if window.id() != window_id { + if let Some(child) = self.children.values_mut().find(|child| { + child + .window + .as_ref() + .is_some_and(|window| window.id() == window_id) + }) { + child.window_event(event_loop, window_id, event); + self.publish_viewport(); + } return; } match event { - WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::CloseRequested => { + if self.resource == crate::window::MAIN_WINDOW { + event_loop.exit(); + } else { + let input = self + .namespace + .read() + .ok() + .map(|namespace| crate::events::Input { + window: self.resource, + session: namespace.document.frame.session, + layout_version: namespace.document.frame.layout_version, + mount: 0, + kind: "window-close".into(), + point: None, + press_point: None, + }); + if let Some(input) = input { + self.send_guest(input); + } + } + } WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { self.publish_viewport(); window.request_redraw(); @@ -566,7 +707,9 @@ impl ApplicationHandler for TesseraApp { } WindowEvent::CursorLeft { .. } => { self.guest_pressed = None; - self.tab_pressed = None; + if self.tab_drag_origin.is_none() { + self.tab_pressed = None; + } self.developer_tools.pointer_left(); window.set_cursor(winit::window::CursorIcon::Default); window.request_redraw(); @@ -579,6 +722,11 @@ impl ApplicationHandler for TesseraApp { if state == ElementState::Pressed { self.guest_keyboard = !self.guest_covered(self.pointer); self.tab_pressed = self.tab_hit(); + self.tab_drag_origin = self + .tab_pressed + .as_ref() + .filter(|input| input.kind == "tab-activate") + .map(|_| self.pointer); self.guest_pressed = self.guest_hit(); if self.guest_keyboard { self.developer_tools.blur(); @@ -589,14 +737,26 @@ impl ApplicationHandler for TesseraApp { &self.activity, ); } else { - if let Some(pressed) = self.tab_pressed.take() - && let Some(released) = self.tab_hit() - && released.session == pressed.session - && released.layout_version == pressed.layout_version - && released.mount == pressed.mount - && released.kind == pressed.kind - { - self.send_guest(released); + if let Some(mut pressed) = self.tab_pressed.take() { + let width = window.inner_size().width as f32 / window.scale_factor() as f32; + let dragged = self.tab_drag_origin.take().is_some_and(|origin| { + (self.pointer.x - origin.x).hypot(self.pointer.y - origin.y) > 12.0 + && (self.pointer.y < 0.0 + || self.pointer.y >= crate::tabs::HEIGHT + 12.0 + || self.pointer.x < 0.0 + || self.pointer.x >= width) + }); + if dragged { + pressed.kind = "tab-detach".into(); + self.send_guest(pressed); + } else if let Some(released) = self.tab_hit() + && released.session == pressed.session + && released.layout_version == pressed.layout_version + && released.mount == pressed.mount + && released.kind == pressed.kind + { + self.send_guest(released); + } } if let Some(pressed) = self.guest_pressed.take() && let Some(mut released) = self.guest_hit() diff --git a/src/document.rs b/src/document.rs index 437eb36..44ff85d 100644 --- a/src/document.rs +++ b/src/document.rs @@ -19,6 +19,7 @@ pub struct NamespaceState { pub submission: u64, pub document: RenderDocument, pub viewport: Option, + pub window_viewports: std::collections::BTreeMap, pub runtime_session: u64, pub window_commands: Vec, } @@ -31,6 +32,7 @@ impl Default for NamespaceState { submission: 0, document: RenderDocument::default(), viewport: None, + window_viewports: std::collections::BTreeMap::new(), runtime_session: 0, window_commands: Vec::new(), } @@ -93,6 +95,8 @@ impl NamespaceState { #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct RenderDocument { + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub windows: std::collections::BTreeMap, #[serde(skip)] pub ui_root: Option>, #[serde(default)] @@ -105,6 +109,18 @@ pub struct RenderDocument { impl RenderDocument { pub fn validate(&self) -> Result<()> { + if self.windows.len() > 15 { + bail!("too many window projections"); + } + for (id, document) in &self.windows { + if *id == crate::window::MAIN_WINDOW + || *id != document.frame.window + || !document.windows.is_empty() + { + bail!("invalid secondary window projection"); + } + document.validate()?; + } if self.source.len() > 1_048_576 { bail!("source exceeds the 1 MiB limit"); } diff --git a/src/endor_host.rs b/src/endor_host.rs index ad1834a..c27ab2d 100644 --- a/src/endor_host.rs +++ b/src/endor_host.rs @@ -86,6 +86,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result, faulted: bool, program_root: Option, + documents: std::collections::BTreeMap, } pub struct ReplResult { pub value: String, @@ -328,6 +330,7 @@ impl ReplSession { nodes: Vec::new(), faulted: false, program_root: None, + documents: std::collections::BTreeMap::new(), }) } pub fn execute(&mut self, source: &str, namespace: &str) -> Result { @@ -467,50 +470,134 @@ impl ReplSession { } fn refresh_document(&mut self) -> Result> { - let mut last = None; let mut work = 0; + let mut changed = false; let mut mutated_mounts = false; - let result = (|| loop { - let Some(root) = self.bridge.prepare_frame(&mut self.candidate.vm)? else { - return Ok(last); - }; - mutated_mounts = true; - let frame = self - .bridge - .layout_frame(&mut self.candidate.vm, root.clone())?; - let mut document = RenderDocument { - source: self.source.clone(), - root: Some(self.program_root.clone().unwrap_or_else(|| CodeNode { - id: format!("console-session-{}", self.id), - name: format!("Console session {}", self.id), - kind: "repl-session".into(), - start_line: 1, - end_line: self.source.lines().count(), - children: self.nodes.clone(), - })), - frame, - ui_root: root.clone(), - }; - document.frame.session = self.id; - document.validate()?; - work += document.frame.mounts.len().max(1); - if work > 200_000 { - bail!("layout dependencies did not stabilize within the rendering work budget"); + let result = (|| { + loop { + let mut pass_changed = false; + for id in self.bridge.window_ids()? { + self.bridge.select_window(id)?; + while let Some(root) = self.bridge.prepare_frame(&mut self.candidate.vm)? { + mutated_mounts = true; + let frame = self + .bridge + .layout_frame(&mut self.candidate.vm, root.clone())?; + let mut document = RenderDocument { + windows: std::collections::BTreeMap::new(), + source: self.source.clone(), + root: Some(self.program_root.clone().unwrap_or_else(|| CodeNode { + id: format!("console-session-{}", self.id), + name: format!("Console session {}", self.id), + kind: "repl-session".into(), + start_line: 1, + end_line: self.source.lines().count(), + children: self.nodes.clone(), + })), + frame, + ui_root: root.clone(), + }; + document.frame.session = self.id; + document.validate()?; + work += document.frame.mounts.len().max(1); + if work > 200_000 { + bail!( + "layout dependencies did not stabilize within the rendering work budget" + ); + } + self.bridge.paint_frame( + &mut self.candidate.vm, + root.as_deref(), + &mut document.frame, + )?; + document.validate()?; + self.documents.insert(id, document); + pass_changed = true; + changed = true; + } + } + if !pass_changed { + break; + } } - self.bridge.paint_frame( - &mut self.candidate.vm, - root.as_deref(), - &mut document.frame, - )?; - document.validate()?; - last = Some(document); + self.bridge.finish_closed(&mut self.candidate.vm)?; + let ids = self.bridge.window_ids()?; + self.documents.retain(|id, _| ids.contains(id)); + if !changed { + return Ok(None); + } + let mut main = self + .documents + .get(&crate::window::MAIN_WINDOW) + .cloned() + .unwrap_or_else(|| RenderDocument { + source: self.source.clone(), + frame: crate::document::Frame { + session: self.id, + ..crate::document::Frame::default() + }, + ..RenderDocument::default() + }); + main.source = self.source.clone(); + main.root = self + .documents + .values() + .find(|document| document.source == self.source) + .and_then(|document| document.root.clone()) + .or(main.root); + main.windows = self + .documents + .iter() + .filter(|(id, _)| **id != crate::window::MAIN_WINDOW) + .map(|(id, document)| (*id, document.clone())) + .collect(); + main.validate()?; + Ok(Some(main)) })(); + self.bridge.select_window(crate::window::MAIN_WINDOW)?; if result.is_err() && mutated_mounts { self.faulted = true; } result } + pub fn resize_windows( + &mut self, + viewports: &std::collections::BTreeMap, + ) -> Result> { + if self.faulted { + bail!("runtime stopped after a callback or rendering failure"); + } + let ids = self.bridge.window_ids()?; + if ids.len() == 1 + && let Some(viewport) = viewports.get(&crate::window::MAIN_WINDOW) + { + self.bridge.select_window(crate::window::MAIN_WINDOW)?; + return self.resize(Some(*viewport)); + } + let mut changed = false; + for (id, viewport) in viewports { + if ids.contains(id) { + self.bridge.select_window(*id)?; + changed |= self + .bridge + .set_viewport(&mut self.candidate.vm, Some(*viewport))?; + } + } + self.bridge.select_window(crate::window::MAIN_WINDOW)?; + if !changed { + return Ok(None); + } + let result = self.refresh_document(); + if result.is_err() { + self.take_window_commands()?; + self.bridge.discard_pending(&mut self.candidate.vm)?; + } + self.bridge + .reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?; + result + } + pub fn take_window_commands(&self) -> Result> { self.bridge.take_window_commands(&self.candidate.vm) } @@ -535,6 +622,10 @@ impl ReplSession { result } + pub fn accepts_window(&self, id: u64) -> bool { + self.bridge.accepts_window(id) + } + pub fn is_faulted(&self) -> bool { self.faulted } @@ -546,6 +637,7 @@ impl ReplSession { if input.session != self.id { bail!("event belongs to another runtime session"); } + self.bridge.select_window(input.window)?; let result = (|| { self.bridge.dispatch(&mut self.candidate.vm, input)?; let document = self.refresh_document()?; @@ -561,6 +653,7 @@ impl ReplSession { input: self.inputs, }) })(); + self.bridge.select_window(crate::window::MAIN_WINDOW)?; if result.is_err() { self.take_window_commands()?; self.faulted = true; @@ -845,6 +938,142 @@ mod tests { const EXAMPLE: &str = include_str!("../examples/hello.endor"); const PAGE: &str = include_str!("../examples/page.endor"); + #[test] + fn native_windows_share_business_state_move_pages_and_release_closed_contexts() { + let (mut session, initial) = ReplSession::load( + include_str!("../examples/windows.endor"), + "app.main", + 1, + 130, + ) + .unwrap(); + assert_eq!(initial.windows.len(), 1); + let main_ids: std::collections::BTreeSet<_> = + initial.frame.mounts.iter().map(|mount| mount.id).collect(); + assert!( + initial.windows[&2] + .frame + .mounts + .iter() + .all(|mount| !main_ids.contains(&mount.id)) + ); + assert_eq!(initial.windows[&2].frame.session, initial.frame.session); + let changed = session + .execute("count.increment()", "app.main") + .unwrap() + .document + .unwrap(); + let has_one = |frame: &crate::document::Frame| { + frame + .commands + .iter() + .any(|command| matches!(command,RenderCommand::Text{value,..} if value=="1")) + }; + assert!(has_one(&changed.frame) && has_one(&changed.windows[&2].frame)); + session + .execute("oldRight = rightButton.mounts().at(0)", "app.main") + .unwrap(); + let page = session.execute("right", "app.main").unwrap().value; + let moved = session + .execute("right.moveToWindow(ui.window())", "app.main") + .unwrap() + .document + .unwrap(); + assert!(moved.windows[&2].frame.tabs.is_empty()); + assert_eq!(session.execute("right", "app.main").unwrap().value, page); + assert_eq!( + session + .execute("right.window().id()", "app.main") + .unwrap() + .value, + "1" + ); + assert!(session.execute("oldRight.bounds()", "app.main").is_err()); + let globals = session.candidate.globals.unwrap(); + let Value::Object(window) = session + .candidate + .vm + .dict_get(globals, &Value::Text("other".into())) + .unwrap() + .unwrap() + else { + panic!("Window"); + }; + let weak = session.candidate.vm.downgrade(window).unwrap(); + let closed = session + .execute("other.close()", "app.main") + .unwrap() + .document + .unwrap(); + assert!(closed.windows.is_empty()); + assert!(session.execute("right.open(other)", "app.main").is_err()); + session + .candidate + .vm + .dict_delete(globals, &Value::Text("other".into())) + .unwrap(); + session.execute("nil", "app.main").unwrap(); + session.candidate.vm.collect_cycles(); + assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); + // An externally retained old leaf Mount must not retain the window context. + let detached = session + .execute("detached = right.detachToWindow()", "app.main") + .unwrap() + .document + .unwrap(); + assert!(detached.windows.contains_key(&3)); + session.execute("detached.close()", "app.main").unwrap(); + assert_eq!( + session.execute("right.window()", "app.main").unwrap().value, + "nil" + ); + assert_eq!( + session.execute("count.value", "app.main").unwrap().value, + "1" + ); + session + .execute("right.open(ui.window())", "app.main") + .unwrap(); + assert_eq!(session.execute("right", "app.main").unwrap().value, page); + } + + #[test] + fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() { + let (mut session, _) = ReplSession::load( + include_str!("../examples/windows.endor"), + "app.main", + 1, + 131, + ) + .unwrap(); + assert!( + session + .execute("other.close()\nui.window().resize(0,600)", "app.main") + .is_err() + ); + assert!(!session.accepts_window(2)); + let repaired = session.execute("right.window()", "app.main").unwrap(); + assert_eq!(repaired.value, "nil"); + assert!(repaired.document.unwrap().windows.is_empty()); + assert_eq!( + session + .execute("right.mounts().count", "app.main") + .unwrap() + .value, + "0" + ); + session + .execute("right.open(ui.window())", "app.main") + .unwrap(); + assert_eq!( + session + .execute("right.window().id()", "app.main") + .unwrap() + .value, + "1" + ); + } + #[test] fn tab_projection_uses_page_methods_and_rejects_old_presentations() { let source = r#"first = Page() diff --git a/src/layout.rs b/src/layout.rs index f810c4e..0023b00 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -221,6 +221,13 @@ impl LayoutRuntime { pub fn invalidate(&mut self, block: &str) { self.sizes.remove(block); } + pub fn reserve_identity_floor(&mut self, floor: u64) { + self.next_id = self.next_id.max(floor); + } + pub fn identity_high_watermark(&self) -> u64 { + self.next_id + } + pub fn clear_occurrences(&mut self) { self.ids.clear(); } diff --git a/src/mount.rs b/src/mount.rs index 883462f..9d8c86c 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -57,7 +57,7 @@ enum Query { #[derive(Debug)] struct Method { owner: ObjectRef, - desktop: ObjectRef, + desktop: WeakObjectRef, query: Query, } @@ -113,12 +113,15 @@ impl NativeCallable for Method { if !mount.geometry.interactive { return Err(VmError::InvalidArgument("Mount is not focusable")); } + let desktop = cx + .upgrade(self.desktop)? + .ok_or(VmError::InvalidArgument("Mount window has expired"))?; cx.set_attr( - self.desktop, + desktop, "focus", Value::Number((mount.geometry.id as i64).into()), )?; - cx.set_attr(self.desktop, "focusRequested", Value::Bool(true))?; + cx.set_attr(desktop, "focusRequested", Value::Bool(true))?; Value::Nil } Query::HitTest => { @@ -292,10 +295,10 @@ impl NativeCallable for Sync { name, Method { owner: object, - desktop: self.desktop, + desktop: cx.store().downgrade(self.desktop)?, query, }, - vec![object, self.desktop], + vec![object], ))?; cx.set_attr(object, name, Value::Object(function))?; } diff --git a/src/presentation.rs b/src/presentation.rs index 8081de3..805ce87 100644 --- a/src/presentation.rs +++ b/src/presentation.rs @@ -8,6 +8,7 @@ use std::sync::Arc; #[derive(Debug)] pub(crate) struct Presentation { pub desktop: ObjectRef, + pub closing: bool, pub tabs: ObjectRef, pub active: Option, pub pending: Option>, @@ -76,6 +77,7 @@ impl Presentation { pub fn new(desktop: ObjectRef, tabs: ObjectRef) -> Self { Self { desktop, + closing: false, tabs, active: None, pending: None, diff --git a/src/ui_bridge.rs b/src/ui_bridge.rs index 3d741dd..eadcc58 100644 --- a/src/ui_bridge.rs +++ b/src/ui_bridge.rs @@ -173,6 +173,10 @@ struct Session { nodes: Vec, presentations: std::collections::BTreeMap, current: u64, + master: ObjectRef, + next_window: u64, + next_mount: u64, + retired_commands: Vec, } impl std::ops::Deref for Session { @@ -202,6 +206,10 @@ enum Operation { Viewport, Window, PageWindow, + NewWindow, + CloseWindow, + Detach, + MoveWindow, State, StateSet, Increment, @@ -445,14 +453,116 @@ fn new_typed_block( Ok(block) } -fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> { - let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; - 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)); +fn create_window( + cx: &mut NativeContext<'_>, + state: &mut Session, + definition: ObjectRef, +) -> Result { + if cx.store().is_read_only() { + return Err(invalid( + "window creation is forbidden during read-only rendering", + )); + } + if state.presentations.len() >= 16 { + return Err(VmError::ExecutionLimit("16 window budget")); + } + let id = state + .next_window + .checked_add(1) + .filter(|id| *id <= i64::MAX as u64) + .ok_or(invalid("window identities exhausted"))?; + let (window, desktop, tabs) = crate::window::create(cx, state.master, id, definition)?; + let mut presentation = crate::presentation::Presentation::new(desktop, tabs); + presentation.force_frame = true; + state.presentations.insert(id, presentation); + state.next_window = id; + Ok(window) +} + +fn page_window( + store: &endor_core::ObjectStore, + state: &Session, + page: ObjectRef, +) -> Result, VmError> { + for (id, view) in &state.presentations { + let window = object(Some(&store.get_attr(view.desktop, "window")?))?; + if crate::window::pages(store, window)?.contains(&page) { + return Ok(Some(*id)); + } + } + Ok(None) +} +fn remove_page( + cx: &mut NativeContext<'_>, + view: &mut crate::presentation::Presentation, + page: ObjectRef, +) -> Result<(), VmError> { + let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; + crate::window::unregister_page(cx, window, page)?; + if view.pending.unwrap_or(view.active) == Some(page) { + let next = crate::window::pages(cx.store(), window)?.first().copied(); + cx.set_attr( + view.desktop, + "pending", + next.map(Value::Object).unwrap_or(Value::Nil), + )?; + view.pending = Some(next); + } Ok(()) } +fn show_in( + cx: &mut NativeContext<'_>, + page: ObjectRef, + session: &Shared, + target: Option, +) -> Result<(), VmError> { + let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; + let old = page_window(cx.store(), &state, page)?; + let id = match target { + Some(window) => { + let data = cx + .store() + .payload::(window, "Window")?; + if data.closed { + return Err(invalid("native window is closed")); + } + let id = data.id; + if state + .presentations + .get(&id) + .is_none_or(|view| view.desktop != data.desktop) + { + return Err(invalid("window belongs to another runtime")); + } + id + } + None => old.unwrap_or(state.current), + }; + if let Some(old) = old + && old != id + { + remove_page( + cx, + state + .presentations + .get_mut(&old) + .ok_or(invalid("missing source window"))?, + page, + )?; + } + let view = state + .presentations + .get_mut(&id) + .ok_or(invalid("missing target window"))?; + let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; + crate::window::register_page(cx, window, page)?; + cx.set_attr(view.desktop, "pending", Value::Object(page))?; + view.pending = Some(Some(page)); + Ok(()) +} +fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> { + show_in(cx, page, session, None) +} fn present_value( cx: &mut NativeContext<'_>, @@ -506,12 +616,13 @@ impl NativeCallable for Function { TextOf => (2, 4), Text => (1, 3), Rect => (2, 4), - OpenValue | Spacer | SetText | SetColor | Append | Remove | Render | Navigate - | OnClick | Present => (1, 1), + MoveWindow | OpenValue | Spacer | SetText | SetColor | Append | Remove | Render + | Navigate | OnClick | Present => (1, 1), Row | Column => (2, MAX_UI_NODES + 2), Button => (1, 1), Replace | Move => (2, 2), - Page | Unmount | Close | Activate | Back | Forward | Children => (0, 0), + Page | Unmount | Close | Activate | Back | Forward | Children | NewWindow + | CloseWindow | Detach => (0, 0), Open => (0, 1), Click | Invalidate => (0, 1), On => (2, 3), @@ -594,6 +705,38 @@ impl NativeCallable for Function { )?; return Ok(NativeResult::Return(Value::Object(set))); } + if matches!(self.operation, NewWindow) { + let mut state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let definition = self + .requested_type + .ok_or(invalid("Window constructor requires a type"))?; + let window = create_window(cx, &mut state, definition)?; + return Ok(NativeResult::Return(Value::Object(window))); + } + if matches!(self.operation, CloseWindow) { + let window = self + .owner + .ok_or(invalid("window close requires a receiver"))?; + let id = cx + .store() + .payload::(window, "Window")? + .id; + crate::window::close(cx, window)?; + let mut state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + if let Some(view) = state.presentations.get_mut(&id) { + cx.set_attr(view.desktop, "pending", Value::Nil)?; + view.pending = Some(None); + view.force_frame = true; + view.closing = true; + } + return Ok(NativeResult::Return(Value::Nil)); + } if matches!(self.operation, Window) { let desktop = self .session @@ -935,55 +1078,61 @@ impl NativeCallable for Function { }]; show(cx, owner, &self.session)?; } + Detach => { + let window = { + let mut state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let definition = desktop_type(cx.store(), "Window", state.master)?; + create_window(cx, &mut state, definition)? + }; + show_in(cx, owner, &self.session, Some(window))?; + return Ok(NativeResult::Return(Value::Object(window))); + } PageWindow => { - let desktop = self + let state = 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) + .map_err(|_| invalid("UI session poisoned"))?; + let value = if let Some(id) = page_window(cx.store(), &state, owner)? { + cx.store() + .get_attr(state.presentations[&id].desktop, "window")? } else { Value::Nil }; return Ok(NativeResult::Return(value)); } - Open | Activate => { - if let Some(requested) = args.first() - && !matches!(requested, Value::Nil) + Open | Activate | MoveWindow => { + if matches!(self.operation, MoveWindow) + && matches!(args.first(), None | Some(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")); - } + return Err(invalid("Page.moveToWindow requires a Window")); } - show(cx, owner, &self.session)?; + let target = match args.first() { + None | Some(Value::Nil) => None, + Some(value) => Some(object(Some(value))?), + }; + show_in(cx, owner, &self.session, target)?; return Ok(NativeResult::Return(Value::Object(owner))); } Unmount => { data.children.clear(); } Close => { - let mut session = self + let mut state = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; - let window = object(Some(&cx.store().get_attr(session.desktop, "window")?))?; - crate::window::unregister_page(cx, window, owner)?; - if session.pending.unwrap_or(session.active) == Some(owner) { - let next = crate::window::pages(cx.store(), window)?.first().copied(); - cx.set_attr( - session.desktop, - "pending", - next.map(Value::Object).unwrap_or(Value::Nil), + if let Some(id) = page_window(cx.store(), &state, owner)? { + remove_page( + cx, + state + .presentations + .get_mut(&id) + .ok_or(invalid("missing window"))?, + owner, )?; - session.pending = Some(next); } return Ok(NativeResult::Return(Value::Nil)); } @@ -1103,16 +1252,84 @@ fn project_tabs( } impl UiBridge { - pub fn take_window_commands(&self, vm: &Vm) -> Result> { - let desktop = self + pub fn window_ids(&self) -> Result> { + let state = 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)?) + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + let mut ids: Vec<_> = state.presentations.keys().copied().collect(); + ids.sort_by_key(|id| { + let view = &state.presentations[id]; + ( + if view.active.is_some() + && view.pending.is_some() + && view.pending.flatten() != view.active + { + 0 + } else { + 1 + }, + *id, + ) + }); + Ok(ids) + } + pub fn accepts_window(&self, id: u64) -> bool { + self.session.lock().is_ok_and(|state| { + state + .presentations + .get(&id) + .is_some_and(|view| !view.closing) + }) + } + + pub fn select_window(&self, id: u64) -> Result<()> { + let mut state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + if !state.presentations.contains_key(&id) { + bail!("window is unavailable"); + } + state.current = id; + Ok(()) + } + pub fn finish_closed(&self, vm: &mut Vm) -> Result<()> { + let mut state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + let ids: Vec<_> = state + .presentations + .iter() + .filter(|(id, view)| **id != crate::window::MAIN_WINDOW && view.closing) + .map(|(id, _)| *id) + .collect(); + let windows = object(Some(&vm.get_attr(state.master, "windows")?))?; + for id in ids { + let desktop = state.presentations[&id].desktop; + let window = object(Some(&vm.get_attr(desktop, "window")?))?; + state + .retired_commands + .extend(crate::window::take_commands(vm, window)?); + vm.dict_delete(windows, &Value::Text(id.to_string()))?; + state.presentations.remove(&id); + } + state.current = crate::window::MAIN_WINDOW; + Ok(()) + } + pub fn take_window_commands(&self, vm: &Vm) -> Result> { + let mut state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + let mut commands = std::mem::take(&mut state.retired_commands); + for view in state.presentations.values() { + let window = object(Some(&vm.get_attr(view.desktop, "window")?))?; + commands.extend(crate::window::take_commands(vm, window)?); + } + commands.sort_by_key(|command| (command.window, command.sequence)); + Ok(commands) } pub fn set_viewport(&self, vm: &mut Vm, viewport: Option) -> Result { @@ -1163,9 +1380,9 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))? - .pending - .flatten() - .is_some()) + .presentations + .values() + .any(|view| view.pending.flatten().is_some() || view.active.is_some())) } pub fn open_value(&self, vm: &mut Vm, value: &Value) -> Result<()> { let function = object(Some(&vm.get_attr(self.ui, "open")?))?; @@ -1195,10 +1412,10 @@ impl UiBridge { .find(|page| vm.object_store().downgrade(*page).ok() == Some(weak)) .ok_or_else(|| anyhow::anyhow!("Page is no longer open"))? }; - let method = if input.kind == "tab-close" { - "close" - } else { - "activate" + let method = match input.kind.as_str() { + "tab-close" => "close", + "tab-detach" => "detachToWindow", + _ => "activate", }; let bound = vm.bind_attr(page, method)?; let result = match &bound { @@ -1230,7 +1447,33 @@ impl UiBridge { { bail!("event belongs to another window"); } - if matches!(input.kind.as_str(), "tab-activate" | "tab-close") { + if input.kind == "window-close" { + let desktop = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))? + .desktop; + let window = object(Some(&vm.get_attr(desktop, "window")?))?; + let bound = vm.bind_attr(window, "close")?; + let result = match &bound { + Value::Object(function) => vm.call_with_limits( + *function, + &[], + endor_core::ExecutionLimits { + max_frames: 128, + max_instructions: 1_000_000, + }, + ), + _ => Err(invalid("Window.close is not callable")), + }; + vm.release_value(bound)?; + vm.release_value(result?)?; + return Ok(()); + } + if matches!( + input.kind.as_str(), + "tab-activate" | "tab-close" | "tab-detach" + ) { return self.dispatch_tab(vm, input); } let (weak, desktop, candidates, viewport) = { @@ -1332,8 +1575,33 @@ impl UiBridge { .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; for presentation in state.presentations.values_mut() { - presentation.pending = None; - vm.set_attr(presentation.desktop, "pending", Value::Nil)?; + let window = object(Some(&vm.get_attr(presentation.desktop, "window")?))?; + let pages = crate::window::pages(vm.object_store(), window)?; + let next = if presentation.closing { + None + } else { + presentation + .active + .filter(|page| pages.contains(page)) + .or_else(|| pages.first().copied()) + }; + // Earlier successful statements in a failed REPL input remain live. + // Repair ownership changes before the next publication so a closed or + // moved Page cannot survive in an old context's mount dictionary. + presentation.pending = if next != presentation.active || presentation.closing { + Some(next) + } else { + None + }; + vm.set_attr( + presentation.desktop, + "pending", + presentation + .pending + .flatten() + .map(Value::Object) + .unwrap_or(Value::Nil), + )?; if presentation .active .is_some_and(|page| vm.object(page).is_err()) @@ -1530,7 +1798,22 @@ impl UiBridge { if let Some(size) = &mut layout.viewport { size.height = (size.height - crate::tabs::height(&tabs)).max(0.0); } + { + let state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + layout.reserve_identity_floor(state.next_mount); + } let mut frame = layout.structure(&root)?; + { + let mut state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + state.next_mount = state.next_mount.max(layout.identity_high_watermark()); + frame.window = state.current; + } let (hooks, mounts) = { let mut state = self .session @@ -1632,6 +1915,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { crate::presentation::Presentation::new(desktop, tabs), )]), current: crate::window::MAIN_WINDOW, + master: desktop, + next_window: crate::window::MAIN_WINDOW, + next_mount: 0, + retired_commands: Vec::new(), })); for (name, operation) in [ ("isInstance", Operation::IsInstance), @@ -1683,7 +1970,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("Column", "ContainerBlock", Some("column")), ("Button", "ContainerBlock", Some("button")), ("Page", "ContainerBlock", Some("page")), - ("Window", "Object", None), + ("Window", "Object", Some("window-create")), ("Mount", "Object", None), ("Event", "Object", Some("event")), ("Subscription", "Object", None), @@ -1722,6 +2009,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "column" => Operation::Column, "button" => Operation::Button, "page" => Operation::Page, + "window-create" => Operation::NewWindow, _ => return Err(invalid("unknown native type allocator").into()), }; let factory = vm.allocate(ContextNativeFunction::new( @@ -1828,6 +2116,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("ContainerBlock", "replace", Operation::Replace), ("ContainerBlock", "move", Operation::Move), ("ContainerBlock", "children", Operation::Children), + ("Window", "close", Operation::CloseWindow), + ("Page", "moveToWindow", Operation::MoveWindow), + ("Page", "detachToWindow", Operation::Detach), ("Page", "render", Operation::Render), ("Page", "unmount", Operation::Unmount), ("Page", "open", Operation::Open), diff --git a/src/window.rs b/src/window.rs index 8f19584..9456d7f 100644 --- a/src/window.rs +++ b/src/window.rs @@ -14,6 +14,7 @@ pub enum Action { Resize { width: f64, height: f64 }, SetTitle(String), Focus, + Close, } #[derive(Debug, Clone, PartialEq)] pub struct Command { @@ -35,6 +36,8 @@ pub struct Envelope { #[derive(Debug, Clone)] pub struct ManagedWindow { pub id: u64, + pub desktop: ObjectRef, + pub closed: bool, tabs: ObjectRef, pages: Vec, sequence: u64, @@ -56,6 +59,7 @@ impl ObjectPayload for ManagedWindow { true } fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { + visit(self.desktop); visit(self.tabs); for page in &self.pages { visit(*page); @@ -101,8 +105,8 @@ impl NativeCallable for Method { .store() .payload::(*owner, "Window")? .clone(); - if data.id != MAIN_WINDOW { - return Err(invalid("native window is unavailable")); + if data.closed && !matches!(self.operation, Operation::Id | Operation::Pages) { + return Err(invalid("native window is closed")); } let expected = match self.operation { Operation::Resize => 3, @@ -118,7 +122,7 @@ impl NativeCallable for Method { Value::Object(object_list::collection(cx, data.pages, self.desktop)?) } Operation::Viewport => { - let Value::Object(input) = cx.store().get_attr(self.desktop, "viewport")? else { + 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")?; @@ -160,7 +164,11 @@ impl NativeCallable for Method { Ok(NativeResult::Return(result)) } } -fn enqueue(cx: &mut NativeContext<'_>, owner: ObjectRef, action: Action) -> Result<(), VmError> { +pub fn enqueue( + cx: &mut NativeContext<'_>, + owner: ObjectRef, + action: Action, +) -> Result<(), VmError> { let mut data = cx .store() .payload::(owner, "Window")? @@ -199,6 +207,9 @@ pub fn register_page( .store() .payload::(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( @@ -272,6 +283,8 @@ pub fn install( } let window = vm.allocate(ManagedWindow { id: MAIN_WINDOW, + desktop, + closed: false, tabs, pages: Vec::new(), sequence: 0, @@ -280,5 +293,75 @@ pub fn install( 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, + 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::(window, "Window")? + .clone(); + if data.closed { + return Ok(()); + } + for page in data.pages.clone() { + unregister_page(cx, window, page)?; + } + data = cx + .store() + .payload::(window, "Window")? + .clone(); + data.closed = true; + cx.replace_payload(window, data)?; + enqueue(cx, window, Action::Close) +}