Add Overlay component to gpui2

Conrad Irwin created

Change summary

crates/gpui2/src/elements/div.rs        |   8 
crates/gpui2/src/elements/mod.rs        |   2 
crates/gpui2/src/elements/overlay.rs    | 203 +++++++++++++++++++++++++++
crates/gpui2/src/geometry.rs            |  16 ++
crates/storybook2/src/stories/scroll.rs |  13 +
crates/ui2/src/components/tooltip.rs    |  49 +++--
6 files changed, 259 insertions(+), 32 deletions(-)

Detailed changes

crates/gpui2/src/elements/div.rs 🔗

@@ -22,7 +22,6 @@ use util::ResultExt;
 
 const DRAG_THRESHOLD: f64 = 2.;
 const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
-const TOOLTIP_OFFSET: Point<Pixels> = Point::new(px(10.0), px(8.0));
 
 pub struct GroupStyle {
     pub group: SharedString,
@@ -419,9 +418,8 @@ pub trait StatefulInteractiveComponent<V: 'static, E: Element<V>>: InteractiveCo
             self.interactivity().tooltip_builder.is_none(),
             "calling tooltip more than once on the same element is not supported"
         );
-        self.interactivity().tooltip_builder = Some(Rc::new(move |view_state, cx| {
-            build_tooltip(view_state, cx).into()
-        }));
+        self.interactivity().tooltip_builder =
+            Some(Rc::new(move |view_state, cx| build_tooltip(view_state, cx)));
 
         self
     }
@@ -965,7 +963,7 @@ where
                                     waiting: None,
                                     tooltip: Some(AnyTooltip {
                                         view: tooltip_builder(view_state, cx),
-                                        cursor_offset: cx.mouse_position() + TOOLTIP_OFFSET,
+                                        cursor_offset: cx.mouse_position(),
                                     }),
                                 });
                                 cx.notify();

crates/gpui2/src/elements/mod.rs 🔗

@@ -1,11 +1,13 @@
 mod div;
 mod img;
+mod overlay;
 mod svg;
 mod text;
 mod uniform_list;
 
 pub use div::*;
 pub use img::*;
+pub use overlay::*;
 pub use svg::*;
 pub use text::*;
 pub use uniform_list::*;

crates/gpui2/src/elements/overlay.rs 🔗

@@ -0,0 +1,203 @@
+use smallvec::SmallVec;
+
+use crate::{
+    point, AnyElement, BorrowWindow, Bounds, Element, LayoutId, ParentComponent, Pixels, Point,
+    Size, Style,
+};
+
+pub struct OverlayState {
+    child_layout_ids: SmallVec<[LayoutId; 4]>,
+}
+
+pub struct Overlay<V> {
+    children: SmallVec<[AnyElement<V>; 2]>,
+    anchor_corner: AnchorCorner,
+    fit_mode: OverlayFitMode,
+    // todo!();
+    // anchor_position: Option<Vector2F>,
+    // position_mode: OverlayPositionMode,
+}
+
+/// overlay gives you a floating element that will avoid overflowing the window bounds.
+/// Its children should have no margin to avoid measurement issues.
+pub fn overlay<V: 'static>() -> Overlay<V> {
+    Overlay {
+        children: SmallVec::new(),
+        anchor_corner: AnchorCorner::TopLeft,
+        fit_mode: OverlayFitMode::SwitchAnchor,
+    }
+}
+
+impl<V> Overlay<V> {
+    /// Sets which corner of the overlay should be anchored to the current position.
+    pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
+        self.anchor_corner = anchor;
+        self
+    }
+
+    /// Snap to window edge instead of switching anchor corner when an overflow would occur.
+    pub fn snap_to_window(mut self) -> Self {
+        self.fit_mode = OverlayFitMode::SnapToWindow;
+        self
+    }
+}
+
+impl<V: 'static> ParentComponent<V> for Overlay<V> {
+    fn children_mut(&mut self) -> &mut SmallVec<[AnyElement<V>; 2]> {
+        &mut self.children
+    }
+}
+
+impl<V: 'static> Element<V> for Overlay<V> {
+    type ElementState = OverlayState;
+
+    fn element_id(&self) -> Option<crate::ElementId> {
+        None
+    }
+
+    fn layout(
+        &mut self,
+        view_state: &mut V,
+        _: Option<Self::ElementState>,
+        cx: &mut crate::ViewContext<V>,
+    ) -> (crate::LayoutId, Self::ElementState) {
+        let child_layout_ids = self
+            .children
+            .iter_mut()
+            .map(|child| child.layout(view_state, cx))
+            .collect::<SmallVec<_>>();
+        let layout_id = cx.request_layout(&Style::default(), child_layout_ids.iter().copied());
+
+        (layout_id, OverlayState { child_layout_ids })
+    }
+
+    fn paint(
+        &mut self,
+        bounds: crate::Bounds<crate::Pixels>,
+        view_state: &mut V,
+        element_state: &mut Self::ElementState,
+        cx: &mut crate::ViewContext<V>,
+    ) {
+        if element_state.child_layout_ids.is_empty() {
+            return;
+        }
+
+        let mut child_min = point(Pixels::MAX, Pixels::MAX);
+        let mut child_max = Point::default();
+        for child_layout_id in &element_state.child_layout_ids {
+            let child_bounds = cx.layout_bounds(*child_layout_id);
+            child_min = child_min.min(&child_bounds.origin);
+            child_max = child_max.max(&child_bounds.lower_right());
+        }
+        let size: Size<Pixels> = (child_max - child_min).into();
+        let origin = bounds.origin;
+
+        let mut desired = self.anchor_corner.get_bounds(origin, size);
+        let limits = Bounds {
+            origin: Point::zero(),
+            size: cx.viewport_size(),
+        };
+
+        match self.fit_mode {
+            OverlayFitMode::SnapToWindow => {
+                // Snap the horizontal edges of the overlay to the horizontal edges of the window if
+                // its horizontal bounds overflow
+                if desired.right() > limits.right() {
+                    desired.origin.x -= desired.right() - limits.right();
+                } else if desired.left() < limits.left() {
+                    desired.origin.x = limits.origin.x;
+                }
+
+                // Snap the vertical edges of the overlay to the vertical edges of the window if
+                // its vertical bounds overflow.
+                if desired.bottom() > limits.bottom() {
+                    desired.origin.y -= desired.bottom() - limits.bottom();
+                } else if desired.top() < limits.top() {
+                    desired.origin.y = limits.origin.y;
+                }
+            }
+            OverlayFitMode::SwitchAnchor => {
+                let mut anchor_corner = self.anchor_corner;
+
+                if desired.left() < limits.left() || desired.right() > limits.right() {
+                    anchor_corner = anchor_corner.switch_axis(Axis::Horizontal);
+                }
+
+                if bounds.top() < limits.top() || bounds.bottom() > limits.bottom() {
+                    anchor_corner = anchor_corner.switch_axis(Axis::Vertical);
+                }
+
+                // Update bounds if needed
+                if anchor_corner != self.anchor_corner {
+                    desired = anchor_corner.get_bounds(origin, size)
+                }
+            }
+            OverlayFitMode::None => {}
+        }
+
+        cx.with_element_offset(desired.origin - bounds.origin, |cx| {
+            for child in &mut self.children {
+                child.paint(view_state, cx);
+            }
+        })
+    }
+}
+
+enum Axis {
+    Horizontal,
+    Vertical,
+}
+
+#[derive(Copy, Clone)]
+pub enum OverlayFitMode {
+    SnapToWindow,
+    SwitchAnchor,
+    None,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum AnchorCorner {
+    TopLeft,
+    TopRight,
+    BottomLeft,
+    BottomRight,
+}
+
+impl AnchorCorner {
+    fn get_bounds(&self, origin: Point<Pixels>, size: Size<Pixels>) -> Bounds<Pixels> {
+        let origin = match self {
+            Self::TopLeft => origin,
+            Self::TopRight => Point {
+                x: origin.x - size.width,
+                y: origin.y,
+            },
+            Self::BottomLeft => Point {
+                x: origin.x,
+                y: origin.y - size.height,
+            },
+            Self::BottomRight => Point {
+                x: origin.x - size.width,
+                y: origin.y - size.height,
+            },
+        };
+
+        Bounds { origin, size }
+    }
+
+    fn switch_axis(self, axis: Axis) -> Self {
+        match axis {
+            Axis::Vertical => match self {
+                AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
+                AnchorCorner::TopRight => AnchorCorner::BottomRight,
+                AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
+                AnchorCorner::BottomRight => AnchorCorner::TopRight,
+            },
+            Axis::Horizontal => match self {
+                AnchorCorner::TopLeft => AnchorCorner::TopRight,
+                AnchorCorner::TopRight => AnchorCorner::TopLeft,
+                AnchorCorner::BottomLeft => AnchorCorner::BottomRight,
+                AnchorCorner::BottomRight => AnchorCorner::BottomLeft,
+            },
+        }
+    }
+}

crates/gpui2/src/geometry.rs 🔗

@@ -421,6 +421,22 @@ impl<T> Bounds<T>
 where
     T: Add<T, Output = T> + Clone + Default + Debug,
 {
+    pub fn top(&self) -> T {
+        self.origin.y.clone()
+    }
+
+    pub fn bottom(&self) -> T {
+        self.origin.y.clone() + self.size.height.clone()
+    }
+
+    pub fn left(&self) -> T {
+        self.origin.x.clone()
+    }
+
+    pub fn right(&self) -> T {
+        self.origin.x.clone() + self.size.width.clone()
+    }
+
     pub fn upper_right(&self) -> Point<T> {
         Point {
             x: self.origin.x.clone() + self.size.width.clone(),

crates/storybook2/src/stories/scroll.rs 🔗

@@ -1,5 +1,6 @@
 use gpui::{div, prelude::*, px, Div, Render, SharedString, Stateful, Styled, View, WindowContext};
 use theme2::ActiveTheme;
+use ui::Tooltip;
 
 pub struct ScrollStory;
 
@@ -35,16 +36,18 @@ impl Render for ScrollStory {
                         } else {
                             color_2
                         };
-                        div().id(id).bg(bg).size(px(100. as f32)).when(
-                            row >= 5 && column >= 5,
-                            |d| {
+                        div()
+                            .id(id)
+                            .tooltip(move |_, cx| Tooltip::text(format!("{}, {}", row, column), cx))
+                            .bg(bg)
+                            .size(px(100. as f32))
+                            .when(row >= 5 && column >= 5, |d| {
                                 d.overflow_scroll()
                                     .child(div().size(px(50.)).bg(color_1))
                                     .child(div().size(px(50.)).bg(color_2))
                                     .child(div().size(px(50.)).bg(color_1))
                                     .child(div().size(px(50.)).bg(color_2))
-                            },
-                        )
+                            })
                     }))
             }))
     }

crates/ui2/src/components/tooltip.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::{Action, AnyView, Div, Render, VisualContext};
+use gpui::{overlay, Action, AnyView, Overlay, Render, VisualContext};
 use settings2::Settings;
 use theme2::{ActiveTheme, ThemeSettings};
 
@@ -68,30 +68,35 @@ impl Tooltip {
 }
 
 impl Render for Tooltip {
-    type Element = Div<Self>;
+    type Element = Overlay<Self>;
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
         let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
-        v_stack()
-            .elevation_2(cx)
-            .font(ui_font)
-            .text_ui_sm()
-            .text_color(cx.theme().colors().text)
-            .py_1()
-            .px_2()
-            .child(
-                h_stack()
-                    .child(self.title.clone())
-                    .when_some(self.key_binding.clone(), |this, key_binding| {
-                        this.justify_between().child(key_binding)
+        overlay().child(
+            // padding to avoid mouse cursor
+            div().pl_2().pt_2p5().child(
+                v_stack()
+                    .elevation_2(cx)
+                    .font(ui_font)
+                    .text_ui_sm()
+                    .text_color(cx.theme().colors().text)
+                    .py_1()
+                    .px_2()
+                    .child(
+                        h_stack()
+                            .child(self.title.clone())
+                            .when_some(self.key_binding.clone(), |this, key_binding| {
+                                this.justify_between().child(key_binding)
+                            }),
+                    )
+                    .when_some(self.meta.clone(), |this, meta| {
+                        this.child(
+                            Label::new(meta)
+                                .size(LabelSize::Small)
+                                .color(TextColor::Muted),
+                        )
                     }),
-            )
-            .when_some(self.meta.clone(), |this, meta| {
-                this.child(
-                    Label::new(meta)
-                        .size(LabelSize::Small)
-                        .color(TextColor::Muted),
-                )
-            })
+            ),
+        )
     }
 }