Skip to main content

gpui_base/
root.rs

1//! Window roots and presentation-layer plugins.
2use crate::input::Copy;
3use crate::{StyledExt, TextSelectionLayer};
4use gpui::{
5    AnyElement, AnyView, App, AppContext, ClipboardItem, Context, Div, Entity, Global,
6    InteractiveElement, IntoElement, KeyBinding, ParentElement, Render, Stateful, StyleRefinement,
7    Styled, Window, actions, div,
8};
9use std::{any::TypeId, rc::Rc};
10
11actions!(root, [Tab, TabPrev]);
12const CONTEXT: &str = "Root";
13
14/// Scan at most one cycle of the rendered tab order, preserving containment
15/// regardless of how many controls exist behind a modal. GPUI exposes traversal
16/// but not candidate enumeration. This scan is synchronous: only the final
17/// focus path is published at the next draw, not the background candidates.
18fn advance_focus(window: &mut Window, cx: &mut App, advance: fn(&mut Window, &mut App)) {
19    let Some(container) = crate::active_focus_trap(window, cx) else {
20        advance(window, cx);
21        return;
22    };
23
24    advance(window, cx);
25    let first_candidate = window.focused(cx);
26    while !container.contains_focused(window, cx) {
27        advance(window, cx);
28        if window.focused(cx) == first_candidate {
29            // The trap has no rendered tab stops. Its own handle remains a
30            // safe focus target even when excluded from ordinary tab order.
31            container.focus(window, cx);
32            break;
33        }
34    }
35}
36
37pub(crate) fn init(cx: &mut App) {
38    cx.bind_keys([
39        KeyBinding::new("tab", Tab, Some(CONTEXT)),
40        KeyBinding::new("shift-tab", TabPrev, Some(CONTEXT)),
41        #[cfg(target_os = "macos")]
42        KeyBinding::new("cmd-c", Copy, Some(CONTEXT)),
43        #[cfg(not(target_os = "macos"))]
44        KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)),
45    ]);
46}
47
48/// A presentation layer's retained, per-window facilities.
49///
50/// Register during explicit application initialization, before creating windows.
51/// The view renders above application content. Base owns the root regardless of
52/// which plugins are registered; Cargo features never select its type.
53///
54/// On every root render, each plugin participates in three stages:
55///
56/// 1. [`RootPlugin::prepare`] synchronizes window state before elements are built.
57/// 2. [`RootPlugin::style`] supplies defaults for the root surface.
58/// 3. [`RootPlugin::decorate`] wraps the completed surface in presentation owned
59///    by the plugin.
60///
61/// The plugin's [`Render`] output is mounted as an overlay above application
62/// content. Plugins and their overlays are processed in registration order, so
63/// later plugins appear above earlier ones. Notify the plugin entity after its
64/// state changes to render the root again; `prepare` and `style` must not notify,
65/// because they run during that render.
66///
67/// Factories are captured when a root is created, so registration affects only
68/// future windows.
69pub trait RootPlugin: Render + Sized {
70    /// Synchronize settings derived from this plugin with the window.
71    ///
72    /// This runs before the root surface and plugin overlays are built. It is
73    /// intended for window-scoped state such as rem size or the active text
74    /// selection scope, not for producing elements.
75    fn prepare(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
76
77    /// Apply this plugin's default styles directly to the root surface.
78    ///
79    /// Styles set on the [`Root`] instance are refined onto the surface after
80    /// this hook and therefore take precedence over plugin defaults.
81    fn style(&self, _surface: &mut Stateful<Div>, _window: &mut Window, _cx: &mut App) {}
82
83    /// Add presentation around the completed root surface.
84    ///
85    /// This runs after plugin defaults and instance styles have been applied.
86    /// Return `surface` unchanged when no outer presentation is needed. Typical
87    /// uses include client-side window borders or another structural wrapper.
88    /// `root` provides read-only access to the root and its application view.
89    fn decorate(
90        &self,
91        surface: AnyElement,
92        _root: &Root,
93        _window: &mut Window,
94        _cx: &mut App,
95    ) -> impl IntoElement {
96        surface
97    }
98}
99
100type PluginFactory = Rc<dyn Fn(&mut Window, &mut Context<Root>) -> Plugin>;
101type Prepare = Rc<dyn Fn(&mut Window, &mut App)>;
102type SurfaceStyle = Rc<dyn Fn(&mut Stateful<Div>, &mut Window, &mut App)>;
103type Decorate = Rc<dyn Fn(AnyElement, &Root, &mut Window, &mut App) -> AnyElement>;
104#[derive(Default)]
105struct PluginRegistry(Vec<(TypeId, PluginFactory)>);
106impl Global for PluginRegistry {}
107struct Plugin {
108    view: AnyView,
109    prepare: Prepare,
110    style: SurfaceStyle,
111    decorate: Decorate,
112}
113
114/// The window's content and overlay host, independent of any styled component library.
115pub struct Root {
116    view: AnyView,
117    style: StyleRefinement,
118    plugins: Vec<Plugin>,
119}
120
121impl Root {
122    /// Register a presentation plugin once per application. Re-registering its
123    /// type replaces the factory for future windows rather than mounting it twice.
124    pub fn register_plugin<V: RootPlugin>(
125        cx: &mut App,
126        build: fn(&mut Window, &mut Context<V>) -> V,
127    ) {
128        if !cx.has_global::<PluginRegistry>() {
129            cx.set_global(PluginRegistry::default());
130        }
131        let factory: PluginFactory = Rc::new(move |window, cx| {
132            let entity = cx.new(|cx| build(window, cx));
133            let root = cx.weak_entity();
134            let observed = entity.clone();
135            // Plugin construction can enqueue notifications. Observe only after
136            // that effect cycle so mounting a Root does not immediately render
137            // application content a second time.
138            cx.defer(move |cx| {
139                cx.observe(&observed, move |_, cx| {
140                    let _ = root.update(cx, |_, cx| cx.notify());
141                })
142                .detach();
143            });
144            let prepare = entity.clone();
145            let style = entity.clone();
146            let decorate = entity.clone();
147            Plugin {
148                view: entity.into(),
149                prepare: Rc::new(move |window, cx| {
150                    prepare.update(cx, |state, cx| state.prepare(window, cx))
151                }),
152                style: Rc::new(move |surface, window, cx| {
153                    style.update(cx, |state, cx| state.style(surface, window, cx))
154                }),
155                decorate: Rc::new(move |surface, root, window, cx| {
156                    decorate.update(cx, |state, cx| {
157                        state.decorate(surface, root, window, cx).into_any_element()
158                    })
159                }),
160            }
161        });
162        let plugins = &mut cx.global_mut::<PluginRegistry>().0;
163        if let Some(entry) = plugins.iter_mut().find(|(id, _)| *id == TypeId::of::<V>()) {
164            entry.1 = factory;
165        } else {
166            plugins.push((TypeId::of::<V>(), factory));
167        }
168    }
169
170    pub fn new(view: impl Into<AnyView>, window: &mut Window, cx: &mut Context<Self>) -> Self {
171        #[cfg(all(target_os = "macos", not(test)))]
172        crate::install_window_hit_test_forwarder(window);
173        let factories = cx
174            .try_global::<PluginRegistry>()
175            .map(|e| e.0.clone())
176            .unwrap_or_default();
177        Self {
178            view: view.into(),
179            style: StyleRefinement::default(),
180            plugins: factories
181                .into_iter()
182                .map(|(_, build)| build(window, cx))
183                .collect(),
184        }
185    }
186
187    /// The original application content entity.
188    pub fn view(&self) -> &AnyView {
189        &self.view
190    }
191
192    /// Find a presentation plugin owned by this window.
193    pub fn plugin<V: RootPlugin>(&self) -> Option<Entity<V>> {
194        self.plugins
195            .iter()
196            .find_map(|entry| entry.view.clone().downcast::<V>().ok())
197    }
198
199    pub fn read<'a>(window: &'a Window, cx: &'a App) -> &'a Self {
200        window
201            .root::<Self>()
202            .flatten()
203            .expect("window must have a Base Root")
204            .read(cx)
205    }
206    pub fn update<R>(
207        window: &mut Window,
208        cx: &mut App,
209        f: impl FnOnce(&mut Self, &mut Window, &mut Context<Self>) -> R,
210    ) -> R {
211        let root = window
212            .root::<Self>()
213            .flatten()
214            .expect("window must have a Base Root");
215        root.update(cx, |root, cx| f(root, window, cx))
216    }
217    fn on_action_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
218        advance_focus(window, cx, Window::focus_next);
219    }
220
221    fn on_action_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context<Self>) {
222        advance_focus(window, cx, Window::focus_prev);
223    }
224
225    fn on_action_copy(&mut self, _: &Copy, window: &mut Window, cx: &mut Context<Self>) {
226        let text = crate::TextSelection::selected_text(window, cx)
227            .trim()
228            .to_string();
229        if text.is_empty() {
230            cx.propagate();
231            return;
232        }
233        cx.write_to_clipboard(ClipboardItem::new_string(text));
234    }
235}
236impl Styled for Root {
237    fn style(&mut self) -> &mut StyleRefinement {
238        &mut self.style
239    }
240}
241impl Render for Root {
242    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
243        for plugin in &self.plugins {
244            (plugin.prepare)(window, cx);
245        }
246        let mut content = div()
247            .id("root")
248            .key_context(CONTEXT)
249            .on_action(cx.listener(Self::on_action_tab))
250            .on_action(cx.listener(Self::on_action_tab_prev))
251            .on_action(cx.listener(Self::on_action_copy))
252            .relative()
253            .size_full()
254            .child(TextSelectionLayer)
255            .child(self.view.clone())
256            .child(
257                div()
258                    .absolute()
259                    .inset_0()
260                    .children(self.plugins.iter().map(|plugin| plugin.view.clone())),
261            );
262        for plugin in &self.plugins {
263            (plugin.style)(&mut content, window, cx);
264        }
265        let mut content = content.refine_style(&self.style).into_any_element();
266        for plugin in &self.plugins {
267            content = (plugin.decorate)(content, self, window, cx);
268        }
269        content
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::FocusTrapElement as _;
277    use gpui::{FocusHandle, Subscription, TestAppContext, VisualTestContext, px};
278    use std::cell::Cell;
279
280    struct TrapContent {
281        background: Vec<FocusHandle>,
282        container: FocusHandle,
283        controls: Vec<FocusHandle>,
284        _focus_subscriptions: Vec<Subscription>,
285    }
286
287    impl Render for TrapContent {
288        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
289            div()
290                .tab_group()
291                .size_full()
292                .children(self.background.iter().enumerate().map(|(index, handle)| {
293                    div()
294                        .id(("background", index))
295                        .track_focus(handle)
296                        .tab_index(0)
297                        .size(px(1.))
298                }))
299                .child(
300                    div()
301                        .id("trap")
302                        .track_focus(&self.container)
303                        .tab_stop(false)
304                        .children(self.controls.iter().enumerate().map(|(index, handle)| {
305                            div()
306                                .id(("control", index))
307                                .track_focus(handle)
308                                .tab_index(0)
309                                .size(px(1.))
310                        }))
311                        .focus_trap("modal", &self.container),
312                )
313        }
314    }
315
316    fn trap_harness(
317        cx: &mut TestAppContext,
318        control_count: usize,
319    ) -> (
320        &mut VisualTestContext,
321        FocusHandle,
322        Vec<FocusHandle>,
323        Vec<FocusHandle>,
324        Rc<Cell<usize>>,
325    ) {
326        cx.update(crate::init);
327        let (container, controls, background) = cx.update(|cx| {
328            (
329                cx.focus_handle(),
330                (0..control_count)
331                    .map(|_| cx.focus_handle().tab_stop(true))
332                    .collect::<Vec<_>>(),
333                // Regression: traversing past the modal must cross more than
334                // the former 100-attempt limit before reaching its other end.
335                (0..256)
336                    .map(|_| cx.focus_handle().tab_stop(true))
337                    .collect::<Vec<_>>(),
338            )
339        });
340        let background_focuses = Rc::new(Cell::new(0));
341        let (_, cx) = cx.add_window_view({
342            let container = container.clone();
343            let controls = controls.clone();
344            let background = background.clone();
345            let background_focuses = background_focuses.clone();
346            move |window, cx| {
347                let content = cx.new(|cx| {
348                    let subscriptions = background
349                        .iter()
350                        .map(|handle| {
351                            let focuses = background_focuses.clone();
352                            cx.on_focus(handle, window, move |_, _, _| {
353                                focuses.set(focuses.get() + 1)
354                            })
355                        })
356                        .collect();
357                    TrapContent {
358                        background,
359                        container,
360                        controls,
361                        _focus_subscriptions: subscriptions,
362                    }
363                });
364                Root::new(content, window, cx)
365            }
366        });
367        cx.update(|window, _| window.activate_window());
368        cx.executor().run_until_parked();
369        cx.update(|window, cx| window.draw(cx).clear(cx));
370        (cx, container, controls, background, background_focuses)
371    }
372
373    fn assert_trapped_focus(
374        cx: &mut VisualTestContext,
375        container: &FocusHandle,
376        expected: &FocusHandle,
377    ) {
378        cx.update(|window, cx| {
379            window.draw(cx).clear(cx);
380            assert_eq!(window.focused(cx).as_ref(), Some(expected));
381            assert!(container.contains_focused(window, cx));
382        });
383    }
384
385    #[gpui::test]
386    fn focus_trap_wraps_forward_past_more_than_100_background_stops(cx: &mut TestAppContext) {
387        let (cx, container, controls, _, background_focuses) = trap_harness(cx, 2);
388        cx.update(|window, cx| {
389            controls[1].focus(window, cx);
390            window.draw(cx).clear(cx);
391        });
392
393        cx.dispatch_action(Tab);
394        assert_trapped_focus(cx, &container, &controls[0]);
395        cx.dispatch_action(Tab);
396        assert_trapped_focus(cx, &container, &controls[1]);
397        assert_eq!(background_focuses.get(), 0);
398    }
399
400    #[gpui::test]
401    fn focus_trap_wraps_backward_past_more_than_100_background_stops(cx: &mut TestAppContext) {
402        let (cx, container, controls, _, background_focuses) = trap_harness(cx, 2);
403        cx.update(|window, cx| {
404            controls[0].focus(window, cx);
405            window.draw(cx).clear(cx);
406        });
407
408        cx.dispatch_action(TabPrev);
409        assert_trapped_focus(cx, &container, &controls[1]);
410        cx.dispatch_action(TabPrev);
411        assert_trapped_focus(cx, &container, &controls[0]);
412        assert_eq!(background_focuses.get(), 0);
413    }
414
415    #[gpui::test]
416    fn focus_trap_without_tab_stops_keeps_its_container_focused(cx: &mut TestAppContext) {
417        let (cx, container, _, _, background_focuses) = trap_harness(cx, 0);
418        cx.update(|window, cx| {
419            container.focus(window, cx);
420            window.draw(cx).clear(cx);
421        });
422
423        cx.dispatch_action(Tab);
424        assert_trapped_focus(cx, &container, &container);
425        cx.dispatch_action(TabPrev);
426        assert_trapped_focus(cx, &container, &container);
427        assert_eq!(background_focuses.get(), 0);
428    }
429
430    #[gpui::test]
431    fn inactive_focus_trap_does_not_capture_background_navigation(cx: &mut TestAppContext) {
432        let (cx, _, _, background, background_focuses) = trap_harness(cx, 2);
433        cx.update(|window, cx| {
434            background[0].focus(window, cx);
435            window.draw(cx).clear(cx);
436        });
437        // Prove that the observers used by the containment regressions really
438        // receive native focus notifications when a background control is used.
439        assert_eq!(background_focuses.get(), 1);
440
441        cx.dispatch_action(Tab);
442        cx.update(|window, cx| {
443            window.draw(cx).clear(cx);
444            assert_eq!(window.focused(cx), Some(background[1].clone()));
445        });
446        cx.dispatch_action(TabPrev);
447        cx.update(|window, cx| {
448            window.draw(cx).clear(cx);
449            assert_eq!(window.focused(cx), Some(background[0].clone()));
450        });
451        assert_eq!(background_focuses.get(), 3);
452    }
453
454    struct Content;
455    impl Render for Content {
456        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
457            div()
458        }
459    }
460    struct Layer;
461    impl Render for Layer {
462        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
463            div()
464        }
465    }
466    impl RootPlugin for Layer {}
467    fn layer(_: &mut Window, _: &mut Context<Layer>) -> Layer {
468        Layer
469    }
470
471    #[gpui::test]
472    fn plugin_registration_is_idempotent_and_state_is_per_window(cx: &mut TestAppContext) {
473        cx.update(|cx| {
474            crate::init(cx);
475            Root::register_plugin(cx, layer);
476            Root::register_plugin(cx, layer);
477        });
478        let mut ids = Vec::new();
479        for _ in 0..2 {
480            let (root, _) = cx.add_window_view(|window, cx| {
481                let content = cx.new(|_| Content);
482                Root::new(content, window, cx)
483            });
484            let id = root.read_with(cx, |root, _| {
485                assert_eq!(root.plugins.len(), 1);
486                root.plugin::<Layer>().unwrap().entity_id()
487            });
488            ids.push(id);
489        }
490        assert_ne!(ids[0], ids[1]);
491    }
492
493    mod nested_traps {
494        use super::*;
495
496        struct NestedTraps {
497            containers: Vec<FocusHandle>,
498            controls: Vec<[FocusHandle; 2]>,
499            depth: usize,
500        }
501
502        impl Render for NestedTraps {
503            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
504                let mut child = div().into_any_element();
505                for index in (0..self.depth).rev() {
506                    let control = |side: usize| {
507                        div()
508                            .id(("nested-control", index * 2 + side))
509                            .track_focus(&self.controls[index][side])
510                            .tab_index(0)
511                            .size(px(10.))
512                    };
513                    child = div()
514                        .tab_stop(false)
515                        .child(control(0))
516                        .child(child)
517                        .child(control(1))
518                        .focus_trap(("nested-trap", index), &self.containers[index])
519                        .into_any_element();
520                }
521                div().tab_group().size_full().child(child)
522            }
523        }
524
525        #[gpui::test]
526        fn nested_traps_keep_the_innermost_scope_across_close_and_reopen(cx: &mut TestAppContext) {
527            cx.update(crate::init);
528            let (containers, controls) = cx.update(|cx| {
529                (
530                    (0..6).map(|_| cx.focus_handle()).collect::<Vec<_>>(),
531                    (0..6)
532                        .map(|_| {
533                            [
534                                cx.focus_handle().tab_stop(true),
535                                cx.focus_handle().tab_stop(true),
536                            ]
537                        })
538                        .collect::<Vec<_>>(),
539                )
540            });
541            let (root, cx) = cx.add_window_view({
542                let containers = containers.clone();
543                let controls = controls.clone();
544                move |window, cx| {
545                    let view = cx.new(|_| NestedTraps {
546                        containers,
547                        controls,
548                        depth: 6,
549                    });
550                    Root::new(view, window, cx)
551                }
552            });
553            let view = root.read_with(cx, |root, _| {
554                root.view.clone().downcast::<NestedTraps>().unwrap()
555            });
556
557            // Retain every container handle while inner scopes disappear and
558            // return. Rendered containment, not registration order or ownership
559            // lifetime, must decide which remaining scope handles Tab.
560            for depth in [6, 5, 4, 3, 2, 1, 2, 3, 4, 5, 6] {
561                view.update(cx, |view, cx| {
562                    view.depth = depth;
563                    cx.notify();
564                });
565                let index = depth - 1;
566                cx.update(|window, cx| {
567                    controls[index][1].focus(window, cx);
568                    window.draw(cx).clear(cx);
569                });
570                cx.dispatch_action(Tab);
571                assert_trapped_focus(cx, &containers[index], &controls[index][0]);
572                cx.dispatch_action(TabPrev);
573                assert_trapped_focus(cx, &containers[index], &controls[index][1]);
574            }
575        }
576    }
577}