Skip to main content

guise/overlay/
host.rs

1//! `OverlayHost` — window-level overlay services (gpui entity).
2//!
3//! One host per window owns the modal stack and the toast queue, so opening
4//! a dialog is a call, not render-flag plumbing: any handler with the host's
5//! entity can `open_modal`/`toast` from anywhere. The host also restores
6//! focus to whatever was focused before a modal opened, and closes the top
7//! modal on Escape.
8//!
9//! ```ignore
10//! // At the root view: create once, render last (so overlays paint above).
11//! let overlays = cx.new(OverlayHost::new);
12//! div().child(page_content).child(overlays.clone())
13//!
14//! // From any handler:
15//! overlays.update(cx, |host, cx| {
16//!     host.toast("Saved", cx);
17//!     host.open_modal(window, cx, |close, _window, _cx| {
18//!         Modal::new()
19//!             .title("Settings")
20//!             .on_close(move |_ev, window, cx| close(window, cx))
21//!             .child(Text::new("..."))
22//!             .into_any_element()
23//!     });
24//! });
25//! ```
26
27use std::rc::Rc;
28
29use gpui::prelude::*;
30use gpui::{
31  div, AnyElement, App, Context, Entity, FocusHandle, IntoElement, KeyDownEvent, SharedString,
32  Window,
33};
34
35use crate::devtools::Probed;
36use crate::feedback::ToastStack;
37use crate::theme::ColorName;
38
39/// Closes the modal it was handed to. Wire it to your modal's close
40/// button/backdrop (`Modal::on_close`).
41pub type ModalCloser = Rc<dyn Fn(&mut Window, &mut App) + 'static>;
42
43type ModalBuilder = Rc<dyn Fn(ModalCloser, &mut Window, &mut App) -> AnyElement + 'static>;
44
45struct ModalEntry {
46  id: usize,
47  builder: ModalBuilder,
48  previous_focus: Option<FocusHandle>,
49}
50
51/// Window-level modal stack + toast queue. Create with
52/// `cx.new(OverlayHost::new)` and render it as the last child of the root.
53pub struct OverlayHost {
54  modals: Vec<ModalEntry>,
55  toasts: Entity<ToastStack>,
56  next_id: usize,
57}
58
59impl OverlayHost {
60  pub fn new(cx: &mut Context<Self>) -> Self {
61    OverlayHost {
62      modals: Vec::new(),
63      toasts: cx.new(|_| ToastStack::new()),
64      next_id: 0,
65    }
66  }
67
68  /// The inner [`ToastStack`], for `duration`/`clear`/`remove` control.
69  pub fn toast_stack(&self) -> Entity<ToastStack> {
70    self.toasts.clone()
71  }
72
73  /// Push a plain toast.
74  pub fn toast(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
75    let message = message.into();
76    self.toasts.update(cx, |toasts, cx| {
77      toasts.push(message, cx);
78    });
79  }
80
81  /// Push a titled, colored toast.
82  pub fn toast_titled(
83    &mut self,
84    title: impl Into<SharedString>,
85    message: impl Into<SharedString>,
86    color: ColorName,
87    cx: &mut Context<Self>,
88  ) {
89    let (title, message) = (title.into(), message.into());
90    self.toasts.update(cx, |toasts, cx| {
91      toasts.push_titled(title, message, color, cx);
92    });
93  }
94
95  /// Open a modal above everything (stacked above any already open). The
96  /// builder is re-invoked every frame (live content) and receives a
97  /// [`ModalCloser`] to wire to its close affordances. Returns the modal's
98  /// id for [`close_modal`](Self::close_modal).
99  ///
100  /// Whatever was focused when the modal opened is refocused when it
101  /// closes; Escape (with focus anywhere inside the modal) closes it.
102  pub fn open_modal<E>(
103    &mut self,
104    window: &mut Window,
105    cx: &mut Context<Self>,
106    builder: impl Fn(ModalCloser, &mut Window, &mut App) -> E + 'static,
107  ) -> usize
108  where
109    E: IntoElement,
110  {
111    let id = self.next_id;
112    self.next_id += 1;
113    self.modals.push(ModalEntry {
114      id,
115      builder: Rc::new(move |close, window, cx| builder(close, window, cx).into_any_element()),
116      previous_focus: window.focused(cx),
117    });
118    cx.notify();
119    id
120  }
121
122  /// Close a modal by id, restoring the focus it captured on open.
123  pub fn close_modal(&mut self, id: usize, window: &mut Window, cx: &mut Context<Self>) {
124    let Some(index) = self.modals.iter().position(|m| m.id == id) else {
125      return;
126    };
127    let entry = self.modals.remove(index);
128    if let Some(focus) = entry.previous_focus {
129      window.focus(&focus);
130    }
131    cx.notify();
132  }
133
134  /// Close the top-most modal, if any.
135  pub fn close_top(&mut self, window: &mut Window, cx: &mut Context<Self>) {
136    if let Some(id) = self.modals.last().map(|m| m.id) {
137      self.close_modal(id, window, cx);
138    }
139  }
140
141  pub fn modal_count(&self) -> usize {
142    self.modals.len()
143  }
144
145  fn closer(&self, id: usize, cx: &mut Context<Self>) -> ModalCloser {
146    let host = cx.entity().downgrade();
147    Rc::new(move |window, cx| {
148      host
149        .update(cx, |host, cx| host.close_modal(id, window, cx))
150        .ok();
151    })
152  }
153}
154
155impl Render for OverlayHost {
156  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
157    let mut root = div().child(self.toasts.clone());
158
159    let top_id = self.modals.last().map(|m| m.id);
160    let entries: Vec<(usize, ModalBuilder)> = self
161      .modals
162      .iter()
163      .map(|m| (m.id, m.builder.clone()))
164      .collect();
165
166    for (id, builder) in entries {
167      let close = self.closer(id, cx);
168      let content = builder(close, window, cx);
169      let is_top = top_id == Some(id);
170      root = root.child(
171        div()
172          .on_key_down(cx.listener(move |this, event: &KeyDownEvent, window, cx| {
173            if is_top && event.keystroke.key == "escape" {
174              this.close_modal(id, window, cx);
175              cx.stop_propagation();
176            }
177          }))
178          .child(content),
179      );
180    }
181    root.probe("OverlayHost")
182  }
183}