diff --git a/app/src/settings_view/environments_page_tests.rs b/app/src/settings_view/environments_page_tests.rs index a33baf15bf..c54e0e4416 100644 --- a/app/src/settings_view/environments_page_tests.rs +++ b/app/src/settings_view/environments_page_tests.rs @@ -1311,7 +1311,7 @@ fn test_toolbar_renders_search_editor_view() { let env_page_id = env_page_handle.id(); let search_editor_id = env_page.search_editor.id(); - let chain = presenter.borrow().ancestors(search_editor_id); + let chain = ctx.view_ancestors(window_id, search_editor_id); assert!( chain.len() >= 2, "Expected search editor to be laid out as a child view; got ancestors={chain:?}" diff --git a/crates/warpui_core/src/core/app.rs b/crates/warpui_core/src/core/app.rs index 4e68ba251b..486065c820 100644 --- a/crates/warpui_core/src/core/app.rs +++ b/crates/warpui_core/src/core/app.rs @@ -700,6 +700,18 @@ pub struct AppContext { /// all views in the source window. structural_parent_to_children: HashMap>, + /// Backend-neutral child-view → parent-view map, per window. This is the view + /// hierarchy the shared core walks for [`Self::view_ancestors`], the responder + /// chain, and focus ancestor propagation — for any backend. + /// + /// Populated from two sources: creation-time structural parentage + /// ([`Self::record_view_parent`], called when a typed-action view is created + /// with a parent) and the active backend's render pass, which reports the + /// child-view embeddings it discovers while laying out a frame + /// ([`Self::report_view_embeddings`]). Entries are removed when views are + /// dropped or transferred out of the window, and when the window closes. + view_parents: HashMap>, + /// When set, all focus changes to this window are suppressed. /// Used during tab drag to prevent the new window from stealing focus. suppress_focus_for_window: Option, @@ -804,6 +816,7 @@ impl AppContext { view_to_window: Default::default(), structural_child_to_parent: Default::default(), structural_parent_to_children: Default::default(), + view_parents: Default::default(), suppress_focus_for_window: None, fallback_font_source_provider: None, }; @@ -965,6 +978,11 @@ impl AppContext { self.presenters.get(&window_id).cloned() } + /// Drops the GUI presentation state for a closed window. + pub(super) fn drop_window_presentation(&mut self, window_id: WindowId) { + self.presenters.remove(&window_id); + } + fn invalidate_all_views_for_window(&mut self, window_id: WindowId) { let Some(window) = self.windows.get(&window_id) else { return; @@ -1376,6 +1394,90 @@ impl AppContext { None } + /// Records that `parent_view_id` is the parent of `view_id` in `window_id`'s + /// view hierarchy. Called when a view is created with an explicit parent, + /// before the first render pass reports the embedding. + pub fn record_view_parent( + &mut self, + window_id: WindowId, + view_id: EntityId, + parent_view_id: EntityId, + ) { + self.view_parents + .entry(window_id) + .or_default() + .insert(view_id, parent_view_id); + } + + /// Render-time hook: merges the child-view → parent-view embeddings the + /// active backend discovered while laying out a frame into the window's + /// neutral view hierarchy. + /// + /// Reported embeddings overwrite previously recorded parentage for the same + /// child view; entries for dropped or transferred views are removed by the + /// view lifecycle rather than by this hook. This accumulate-and-remove + /// semantic matches the GUI presenter's historical layout-time parent map, + /// so views that are alive but not embedded in the current frame keep their + /// last known ancestry. + pub fn report_view_embeddings( + &mut self, + window_id: WindowId, + embeddings: HashMap, + ) { + self.view_parents + .entry(window_id) + .or_default() + .extend(embeddings); + } + + /// Returns the ancestor chain of `view_id` in `window_id`, ordered from the + /// root down to (and including) `view_id` itself, by walking the neutral + /// view hierarchy. + pub fn view_ancestors(&self, window_id: WindowId, mut view_id: EntityId) -> Vec { + let mut chain = vec![view_id]; + if let Some(parents) = self.view_parents.get(&window_id) { + while let Some(parent_id) = parents.get(&view_id) { + if chain.contains(parent_id) { + log::error!("Cycle detected in the view hierarchy at view {parent_id}"); + break; + } + view_id = *parent_id; + chain.push(view_id); + } + } + chain.reverse(); + chain + } + + /// Returns all descendant view IDs of `root_view_id` in `window_id`, + /// computed by finding all views in the neutral view hierarchy whose + /// ancestor chain includes the root. + pub fn view_descendants(&self, window_id: WindowId, root_view_id: EntityId) -> Vec { + let Some(parents) = self.view_parents.get(&window_id) else { + return Vec::new(); + }; + parents + .keys() + .filter(|&&view_id| { + let mut current = view_id; + let mut steps = 0; + while let Some(&parent_id) = parents.get(¤t) { + if parent_id == root_view_id { + return true; + } + current = parent_id; + // Defend against a (should-be-impossible) cycle. + steps += 1; + if steps > parents.len() { + break; + } + } + false + }) + .copied() + .collect() + } + pub fn dispatch_action_for_view( &mut self, window_id: WindowId, @@ -1383,8 +1485,8 @@ impl AppContext { name: &str, arg: &dyn Any, ) -> bool { - if let Some(presenter) = self.presenter(window_id) { - let responder_chain = presenter.borrow().ancestors(view_id); + if self.is_window_open(window_id) { + let responder_chain = self.view_ancestors(window_id, view_id); self.dispatch_action(window_id, &responder_chain, name, arg, log::Level::Info) } else { false @@ -1398,8 +1500,8 @@ impl AppContext { view_id: EntityId, action: &dyn Action, ) { - if let Some(presenter) = self.presenter(window_id) { - let responder_chain = presenter.borrow().ancestors(view_id); + if self.is_window_open(window_id) { + let responder_chain = self.view_ancestors(window_id, view_id); self.dispatch_typed_action(window_id, &responder_chain, action, log::Level::Info); } } @@ -1765,11 +1867,7 @@ impl AppContext { } fn contexts_for_window_and_view(&self, window_id: WindowId, view_id: EntityId) -> Vec { - let responder_chain = self - .presenter(window_id) - .expect("Invalid window id") - .borrow() - .ancestors(view_id); + let responder_chain = self.view_ancestors(window_id, view_id); match self.contexts_from_responder_chain(window_id, &responder_chain) { Ok(ctxs) => ctxs, Err(error) => { @@ -1816,11 +1914,7 @@ impl AppContext { window_id: WindowId, view_id: EntityId, ) -> Vec> { - let responder_chain = self - .presenter(window_id) - .expect("Invalid window id") - .borrow() - .ancestors(view_id); + let responder_chain = self.view_ancestors(window_id, view_id); let contexts = match self.contexts_from_responder_chain(window_id, &responder_chain) { Ok(ctxs) => ctxs, Err(error) => { @@ -1896,11 +1990,7 @@ impl AppContext { /// dispatches to the root view. fn get_responder_chain(&self, window_id: WindowId) -> Vec { if let Some(focused) = self.focused_view_id(window_id) { - if let Some(presenter) = self.presenter(window_id) { - presenter.borrow().ancestors(focused) - } else { - vec![] - } + self.view_ancestors(window_id, focused) } else if let Some(root) = self.root_view_id(window_id) { vec![root] } else { @@ -1912,6 +2002,14 @@ impl AppContext { self.keystroke_matcher.custom_action_bindings() } + /// Dispatches a custom action through the focused view's responder chain, + /// falling back to replaying the bound keystroke when key-binding dispatch + /// is disabled (i.e. while the user is editing their keybindings). + /// + /// Custom actions themselves are backend-neutral (the matcher/registry + /// machinery lives in `app.rs`); this entry point is GUI-only solely + /// because the keystroke-replay fallback drives the presenter-backed event + /// loop, which doesn't exist under the `tui` backend. pub fn dispatch_custom_action(&mut self, action: Action, window_id: WindowId) where Action: Into + Debug + Copy, @@ -2260,24 +2358,12 @@ impl AppContext { options: AddWindowOptions, build_root_view: F, ) -> (WindowId, ViewHandle) - where - T: View + TypedActionView, - F: FnOnce(&mut ViewContext) -> T, - { - self.insert_window(options, build_root_view) - } - - fn insert_window( - &mut self, - add_window_options: AddWindowOptions, - build_root_view: F, - ) -> (WindowId, ViewHandle) where T: View + TypedActionView, F: FnOnce(&mut ViewContext) -> T, { let (window_id, _root_view_id) = - self.insert_window_internal(None, add_window_options, |window_id, ctx| { + self.insert_window_internal(None, options, |window_id, ctx| { ctx.windows.insert(window_id, Window::default()); let root_handle = ctx.add_typed_action_view(window_id, build_root_view); let root_view_id = root_handle.id(); @@ -2294,7 +2380,11 @@ impl AppContext { ) } - fn insert_window_internal( + /// GUI window creation: allocate the window id + bounds bookkeeping, create + /// the [`Presenter`], open the platform window and wire its + /// [`WindowCallbacks`] (event/scene/resize loop), build the root view, focus + /// it, and register the redraw invalidation callback. + pub(super) fn insert_window_internal( &mut self, window_id: Option, add_window_options: AddWindowOptions, @@ -2570,11 +2660,12 @@ impl AppContext { WindowManager::handle(self).update(self, |windowing_state, ctx| { windowing_state.remove_window(window_id, ctx); }); - self.presenters.remove(&window_id); + self.drop_window_presentation(window_id); self.invalidation_callbacks.remove(&window_id); self.window_invalidations.remove(&window_id); self.last_observed_active_cursor_positions .remove(&window_id); + self.view_parents.remove(&window_id); autotracking::close_window(window_id); let mut subscriptions = HashMap::new(); @@ -2908,9 +2999,7 @@ impl AppContext { // If a parent view ID was provided, add the view as a child of the parent if let Some(parent_view_id) = parent_view_id { - if let Some(presenter) = self.presenter(window_id) { - presenter.borrow_mut().set_parent(view_id, parent_view_id); - } + self.record_view_parent(window_id, view_id, parent_view_id); self.structural_child_to_parent .insert(view_id, parent_view_id); self.structural_parent_to_children @@ -2967,6 +3056,12 @@ impl AppContext { .removed .insert(view_id); + // The view's parentage in the source window no longer applies; the + // target window's render pass will report its new embedding. + if let Some(parents) = self.view_parents.get_mut(&source_window_id) { + parents.remove(&view_id); + } + let Some(target_window) = self.windows.get_mut(&target_window_id) else { // Target window doesn't exist - roll back by putting the view back in source window if let Some(source_window) = self.windows.get_mut(&source_window_id) { @@ -3006,8 +3101,8 @@ impl AppContext { /// Transfers a view and all its descendant views from one window to another. /// /// This is useful when transferring a component like a tab that contains - /// multiple nested views. The view tree is determined by the presenter's - /// parent-child relationships. + /// multiple nested views. The view tree is determined by the neutral view + /// hierarchy's parent-child relationships. /// /// Returns the list of view IDs that were transferred. pub fn transfer_view_tree_to_window( @@ -3020,10 +3115,7 @@ impl AppContext { return vec![root_view_id]; } - let descendants = self - .presenter(source_window_id) - .map(|presenter| presenter.borrow().descendants(root_view_id)) - .unwrap_or_default(); + let descendants = self.view_descendants(source_window_id, root_view_id); let mut transferred = Vec::with_capacity(descendants.len() + 1); @@ -3130,6 +3222,9 @@ impl AppContext { .insert(view_id); window.views.remove(&view_id); } + if let Some(parents) = self.view_parents.get_mut(¤t_window_id) { + parents.remove(&view_id); + } autotracking::remove_view(current_window_id, view_id); } @@ -3199,6 +3294,9 @@ impl AppContext { /// /// This operation is destructive: It will clear the caches for both manual and autotracked /// invalidations. + /// + /// GUI-only for now; the M8 TUI runtime may move this back into shared code + /// once it drives its own draw loop. pub(super) fn take_all_invalidations_for_window( &mut self, window_id: WindowId, @@ -3248,7 +3346,7 @@ impl AppContext { } = &event { if let Some(focused_view_id) = self.focused_view_id(window_id) { - let responder_chain = presenter.borrow().ancestors(focused_view_id); + let responder_chain = self.view_ancestors(window_id, focused_view_id); match self.dispatch_keystroke( window_id, &responder_chain, @@ -3278,7 +3376,7 @@ impl AppContext { // (2) the event is a valid interaction (we exclude mouse and scroll movements to reduce noise) if handled && !matches!(event, Event::MouseMoved { .. } | Event::ScrollWheel { .. }) { if let Some(focused_view_id) = self.focused_view_id(window_id) { - let responder_chain = presenter.borrow().ancestors(focused_view_id); + let responder_chain = self.view_ancestors(window_id, focused_view_id); self.dispatch_self_or_child_interacted_with(window_id, &responder_chain); } } @@ -3595,7 +3693,7 @@ impl AppContext { } for action in dispatch_result.actions.into_iter().rev() { - let responder_chain = presenter.borrow().ancestors(action.view_id); + let responder_chain = self.view_ancestors(window_id, action.view_id); match action.kind { DispatchedActionKind::Legacy { name, arg } => { self.dispatch_action( @@ -3825,26 +3923,23 @@ impl AppContext { .views .insert(blurred_id, blurred); - if let Some(presenter) = self.presenter(window_id) { - let blur_ctx = BlurContext::DescendentBlurred(blurred_id); - // Skip the last entry, it is the blurred view itself. - for view_id in presenter - .borrow() - .ancestors(blurred_id) - .into_iter() - .rev() - .skip(1) + let blur_ctx = BlurContext::DescendentBlurred(blurred_id); + // Skip the last entry, it is the blurred view itself. + for view_id in self + .view_ancestors(window_id, blurred_id) + .into_iter() + .rev() + .skip(1) + { + if let Some(mut view) = self + .windows + .get_mut(&window_id) + .and_then(|w| w.views.remove(&view_id)) { - if let Some(mut view) = self - .windows + view.on_blur(&blur_ctx, self, window_id, view_id); + self.windows .get_mut(&window_id) - .and_then(|w| w.views.remove(&view_id)) - { - view.on_blur(&blur_ctx, self, window_id, view_id); - self.windows - .get_mut(&window_id) - .and_then(|w| w.views.insert(view_id, view)); - } + .and_then(|w| w.views.insert(view_id, view)); } } } @@ -3868,26 +3963,23 @@ impl AppContext { .views .insert(focused_id, focused); - if let Some(presenter) = self.presenter(window_id) { - let focus_ctx = FocusContext::DescendentFocused(focused_id); - // Skip the last entry, it is the focused view itself. - for view_id in presenter - .borrow() - .ancestors(focused_id) - .into_iter() - .rev() - .skip(1) + let focus_ctx = FocusContext::DescendentFocused(focused_id); + // Skip the last entry, it is the focused view itself. + for view_id in self + .view_ancestors(window_id, focused_id) + .into_iter() + .rev() + .skip(1) + { + if let Some(mut view) = self + .windows + .get_mut(&window_id) + .and_then(|w| w.views.remove(&view_id)) { - if let Some(mut view) = self - .windows + view.on_focus(&focus_ctx, self, window_id, view_id); + self.windows .get_mut(&window_id) - .and_then(|w| w.views.remove(&view_id)) - { - view.on_focus(&focus_ctx, self, window_id, view_id); - self.windows - .get_mut(&window_id) - .and_then(|w| w.views.insert(view_id, view)); - } + .and_then(|w| w.views.insert(view_id, view)); } } } @@ -4056,14 +4148,7 @@ impl AppContext { Some(id) => id, None => return false, }; - let presenter = match self.presenter(window_id) { - Some(p) => p, - None => return false, - }; - - let borrowed_presenter = presenter.borrow(); - borrowed_presenter - .ancestors(focused_view_id) + self.view_ancestors(window_id, focused_view_id) .contains(view_id) } @@ -4236,10 +4321,15 @@ impl AppContext { } } + /// Snapshot of the window's child-view → parent-view map (for debug tooling). + fn view_parent_map(&self, window_id: WindowId) -> HashMap { + self.view_parents + .get(&window_id) + .cloned() + .unwrap_or_default() + } + pub fn open_view_tree_debug_window(&mut self, target_window_id: WindowId) { - let Some(presenter) = self.presenter(target_window_id) else { - return; - }; let Some(root_view_id) = self.root_view_id(target_window_id) else { return; }; @@ -4261,13 +4351,9 @@ impl AppContext { title: Some("View Tree Debugger".to_owned()), ..Default::default() }; + let view_parents = self.view_parent_map(target_window_id); self.add_window(options, |ctx| { - crate::debug::DebugRootView::new( - target_window_id, - presenter.borrow().parents(), - root_view_id, - ctx, - ) + crate::debug::DebugRootView::new(target_window_id, view_parents, root_view_id, ctx) }); } @@ -4508,7 +4594,6 @@ impl AppContext { }) .ok_or_else(|| anyhow!("window not found")) } - /// Returns the cached element position from the last rendered frame, if there is one. pub fn element_position_by_id_at_last_frame( &self, diff --git a/crates/warpui_core/src/presenter.rs b/crates/warpui_core/src/presenter.rs index 3c7c5506a2..63718e12fa 100644 --- a/crates/warpui_core/src/presenter.rs +++ b/crates/warpui_core/src/presenter.rs @@ -28,7 +28,6 @@ pub struct Presenter { window_id: WindowId, scene: Option>, rendered_views: HashMap>, - parents: HashMap, text_layout_cache: LayoutCache, position_cache: PositionCache, highlighted_view: Option, @@ -306,7 +305,6 @@ impl Presenter { frame_count: 0, window_id, rendered_views: HashMap::new(), - parents: HashMap::new(), scene: None, text_layout_cache: LayoutCache::new(), position_cache: PositionCache::default(), @@ -326,7 +324,6 @@ impl Presenter { } for view_id in invalidation.removed { self.rendered_views.remove(&view_id); - self.parents.remove(&view_id); } } @@ -346,7 +343,13 @@ impl Presenter { let zoomed_window_size = window_size.scale_down(ctx.zoom_factor()); let zoomed_scale_factor = scale_factor.scale_up(ctx.zoom_factor()); - self.layout(zoomed_window_size, ctx); + // Collect the child-view embeddings discovered during layout and report + // them to the backend-neutral view hierarchy on the app context, which + // the shared core walks for ancestors/responder-chain/focus propagation. + // (Reported as a batch because the layout walk itself only has `&AppContext`.) + let mut view_embeddings = HashMap::new(); + self.layout(zoomed_window_size, &mut view_embeddings, ctx); + ctx.report_view_embeddings(self.window_id, view_embeddings); // In theory, after_layout would be a good place for Elements to update app state with the // results of layout (for example, if a View stored the heights of its children to // implement scrolling). However, it's not safe to pass a AppContext to after_layout @@ -374,11 +377,16 @@ impl Presenter { scene } - fn layout(&mut self, window_size: Vector2F, app: &AppContext) { + fn layout( + &mut self, + window_size: Vector2F, + parents: &mut HashMap, + app: &AppContext, + ) { if let Some(root_view_id) = app.root_view_id(self.window_id) { let mut layout_ctx = LayoutContext { rendered_views: &mut self.rendered_views, - parents: &mut self.parents, + parents, text_layout_cache: &self.text_layout_cache, view_stack: Vec::new(), window_size, @@ -463,35 +471,6 @@ impl Presenter { (scene, repaint_at, pending_assets) } - pub fn ancestors(&self, mut view_id: EntityId) -> Vec { - let mut chain = vec![view_id]; - while let Some(parent_id) = self.parents.get(&view_id) { - view_id = *parent_id; - chain.push(view_id); - } - chain.reverse(); - chain - } - - /// Returns all descendant view IDs of the given root view. - /// This is computed by finding all views whose ancestor chain includes the root. - pub fn descendants(&self, root_view_id: EntityId) -> Vec { - self.parents - .keys() - .filter(|&&view_id| { - let mut current = view_id; - while let Some(&parent_id) = self.parents.get(¤t) { - if parent_id == root_view_id { - return true; - } - current = parent_id; - } - false - }) - .copied() - .collect() - } - fn create_event_context<'a>(&'a mut self, font_cache: &'a fonts::Cache) -> EventContext<'a> { EventContext { scene: self.scene.clone(), @@ -553,10 +532,6 @@ impl Presenter { self.frame_count } - pub(crate) fn parents(&self) -> HashMap { - self.parents.clone() - } - pub fn set_highlighted_view(&mut self, view_id: EntityId) { self.highlighted_view = Some(view_id); } @@ -568,13 +543,6 @@ impl Presenter { pub fn text_layout_cache(&self) -> &LayoutCache { &self.text_layout_cache } - - /// Set the parent of a view. - /// This will be overwritten on the next layout pass, but is useful before the initial layout - /// of a view. - pub(crate) fn set_parent(&mut self, view_id: EntityId, parent_id: EntityId) { - self.parents.insert(view_id, parent_id); - } } impl LayoutContext<'_> {