Merge pull request #1193 from zed-industries/tooltips

Antonio Scandurra created

Add some tooltips to aid discoverability

Change summary

crates/contacts_panel/src/contacts_panel.rs |   6 
crates/context_menu/src/context_menu.rs     |   2 
crates/diagnostics/src/items.rs             |  10 ++
crates/editor/src/element.rs                |   2 
crates/gpui/src/elements.rs                 |   4 
crates/gpui/src/elements/overlay.rs         |  50 ++++++++---
crates/gpui/src/elements/tooltip.rs         |  58 +++++++------
crates/workspace/src/sidebar.rs             | 100 +++++++++++-----------
crates/workspace/src/workspace.rs           |  19 +++
crates/zed/src/zed.rs                       |  14 ++
10 files changed, 162 insertions(+), 103 deletions(-)

Detailed changes

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -350,6 +350,8 @@ impl ContactsPanel {
         is_selected: bool,
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
+        enum ToggleOnline {}
+
         let project = &contact.projects[project_index];
         let project_id = project.id;
         let is_host = Some(contact.user.id) == current_user_id;
@@ -445,7 +447,7 @@ impl ContactsPanel {
                                                 project: Some(open_project.clone()),
                                             })
                                         })
-                                        .with_tooltip(
+                                        .with_tooltip::<ToggleOnline, _>(
                                             project_id as usize,
                                             "Take project offline".to_string(),
                                             None,
@@ -565,7 +567,7 @@ impl ContactsPanel {
                                     project: Some(project.clone()),
                                 })
                             })
-                            .with_tooltip(
+                            .with_tooltip::<ToggleOnline, _>(
                                 project_id,
                                 "Take project online".to_string(),
                                 None,

crates/context_menu/src/context_menu.rs 🔗

@@ -94,7 +94,7 @@ impl View for ContextMenu {
 
         Overlay::new(expanded_menu)
             .hoverable(true)
-            .move_to_fit(true)
+            .fit_mode(OverlayFitMode::SnapToWindow)
             .with_abs_position(self.position)
             .boxed()
     }

crates/diagnostics/src/items.rs 🔗

@@ -86,10 +86,11 @@ impl View for DiagnosticIndicator {
         enum Summary {}
         enum Message {}
 
+        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
         let in_progress = !self.in_progress_checks.is_empty();
         let mut element = Flex::row().with_child(
             MouseEventHandler::new::<Summary, _, _>(0, cx, |state, cx| {
-                let style = &cx
+                let style = cx
                     .global::<Settings>()
                     .theme
                     .workspace
@@ -161,6 +162,13 @@ impl View for DiagnosticIndicator {
             })
             .with_cursor_style(CursorStyle::PointingHand)
             .on_click(|_, _, cx| cx.dispatch_action(crate::Deploy))
+            .with_tooltip::<Summary, _>(
+                0,
+                "Project Diagnostics".to_string(),
+                Some(Box::new(crate::Deploy)),
+                tooltip_style,
+                cx,
+            )
             .aligned()
             .boxed(),
         );

crates/editor/src/element.rs 🔗

@@ -883,7 +883,7 @@ impl EditorElement {
                             })
                             .with_cursor_style(CursorStyle::PointingHand)
                             .on_click(move |_, _, cx| cx.dispatch_action(jump_action.clone()))
-                            .with_tooltip(
+                            .with_tooltip::<JumpIcon, _>(
                                 *key,
                                 "Jump to Buffer".to_string(),
                                 Some(Box::new(crate::OpenExcerpts)),

crates/gpui/src/elements.rs 🔗

@@ -157,7 +157,7 @@ pub trait Element {
         FlexItem::new(self.boxed()).float()
     }
 
-    fn with_tooltip<T: View>(
+    fn with_tooltip<Tag: 'static, T: View>(
         self,
         id: usize,
         text: String,
@@ -168,7 +168,7 @@ pub trait Element {
     where
         Self: 'static + Sized,
     {
-        Tooltip::new(id, text, action, style, self.boxed(), cx)
+        Tooltip::new::<Tag, T>(id, text, action, style, self.boxed(), cx)
     }
 }
 

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

@@ -1,25 +1,31 @@
-use serde_json::json;
-
 use crate::{
     geometry::{rect::RectF, vector::Vector2F},
     json::ToJson,
     DebugContext, Element, ElementBox, Event, EventContext, LayoutContext, MouseRegion,
     PaintContext, SizeConstraint,
 };
+use serde_json::json;
 
 pub struct Overlay {
     child: ElementBox,
     abs_position: Option<Vector2F>,
-    move_to_fit: bool,
+    fit_mode: OverlayFitMode,
     hoverable: bool,
 }
 
+#[derive(Copy, Clone)]
+pub enum OverlayFitMode {
+    SnapToWindow,
+    FlipAlignment,
+    None,
+}
+
 impl Overlay {
     pub fn new(child: ElementBox) -> Self {
         Self {
             child,
             abs_position: None,
-            move_to_fit: false,
+            fit_mode: OverlayFitMode::None,
             hoverable: false,
         }
     }
@@ -29,8 +35,8 @@ impl Overlay {
         self
     }
 
-    pub fn move_to_fit(mut self, align_to_fit: bool) -> Self {
-        self.move_to_fit = align_to_fit;
+    pub fn fit_mode(mut self, fit_mode: OverlayFitMode) -> Self {
+        self.fit_mode = fit_mode;
         self
     }
 
@@ -76,18 +82,32 @@ impl Element for Overlay {
             });
         }
 
-        if self.move_to_fit {
-            // Snap the right edge of the overlay to the right edge of the window if
-            // its horizontal bounds overflow.
-            if bounds.lower_right().x() > cx.window_size.x() {
-                bounds.set_origin_x((cx.window_size.x() - bounds.width()).max(0.));
+        match self.fit_mode {
+            OverlayFitMode::SnapToWindow => {
+                // Snap the right edge of the overlay to the right edge of the window if
+                // its horizontal bounds overflow.
+                if bounds.lower_right().x() > cx.window_size.x() {
+                    bounds.set_origin_x((cx.window_size.x() - bounds.width()).max(0.));
+                }
+
+                // Snap the bottom edge of the overlay to the bottom edge of the window if
+                // its vertical bounds overflow.
+                if bounds.lower_right().y() > cx.window_size.y() {
+                    bounds.set_origin_y((cx.window_size.y() - bounds.height()).max(0.));
+                }
             }
+            OverlayFitMode::FlipAlignment => {
+                // Right-align overlay if its horizontal bounds overflow.
+                if bounds.lower_right().x() > cx.window_size.x() {
+                    bounds.set_origin_x(bounds.origin_x() - bounds.width());
+                }
 
-            // Snap the bottom edge of the overlay to the bottom edge of the window if
-            // its vertical bounds overflow.
-            if bounds.lower_right().y() > cx.window_size.y() {
-                bounds.set_origin_y((cx.window_size.y() - bounds.height()).max(0.));
+                // Bottom-align overlay if its vertical bounds overflow.
+                if bounds.lower_right().y() > cx.window_size.y() {
+                    bounds.set_origin_y(bounds.origin_y() - bounds.height());
+                }
             }
+            OverlayFitMode::None => {}
         }
 
         self.child.paint(bounds.origin(), bounds, cx);

crates/gpui/src/elements/tooltip.rs 🔗

@@ -1,6 +1,6 @@
 use super::{
     ContainerStyle, Element, ElementBox, Flex, KeystrokeLabel, MouseEventHandler, Overlay,
-    ParentElement, Text,
+    OverlayFitMode, ParentElement, Text,
 };
 use crate::{
     fonts::TextStyle,
@@ -49,7 +49,7 @@ pub struct KeystrokeStyle {
 }
 
 impl Tooltip {
-    pub fn new<T: View>(
+    pub fn new<Tag: 'static, T: View>(
         id: usize,
         text: String,
         action: Option<Box<dyn Action>>,
@@ -57,7 +57,10 @@ impl Tooltip {
         child: ElementBox,
         cx: &mut RenderContext<T>,
     ) -> Self {
-        let state_handle = cx.element_state::<TooltipState, Rc<TooltipState>>(id);
+        struct ElementState<Tag>(Tag);
+        struct MouseEventHandlerState<Tag>(Tag);
+
+        let state_handle = cx.element_state::<ElementState<Tag>, Rc<TooltipState>>(id);
         let state = state_handle.read(cx).clone();
         let tooltip = if state.visible.get() {
             let mut collapsed_tooltip = Self::render_tooltip(
@@ -79,40 +82,41 @@ impl Tooltip {
                         })
                         .boxed(),
                 )
-                .move_to_fit(true)
+                .fit_mode(OverlayFitMode::FlipAlignment)
                 .with_abs_position(state.position.get())
                 .boxed(),
             )
         } else {
             None
         };
-        let child = MouseEventHandler::new::<Self, _, _>(id, cx, |_, _| child)
-            .on_hover(move |position, hover, cx| {
-                let window_id = cx.window_id();
-                if let Some(view_id) = cx.view_id() {
-                    if hover {
-                        if !state.visible.get() {
-                            state.position.set(position);
+        let child =
+            MouseEventHandler::new::<MouseEventHandlerState<Tag>, _, _>(id, cx, |_, _| child)
+                .on_hover(move |position, hover, cx| {
+                    let window_id = cx.window_id();
+                    if let Some(view_id) = cx.view_id() {
+                        if hover {
+                            if !state.visible.get() {
+                                state.position.set(position);
 
-                            let mut debounce = state.debounce.borrow_mut();
-                            if debounce.is_none() {
-                                *debounce = Some(cx.spawn({
-                                    let state = state.clone();
-                                    |mut cx| async move {
-                                        cx.background().timer(DEBOUNCE_TIMEOUT).await;
-                                        state.visible.set(true);
-                                        cx.update(|cx| cx.notify_view(window_id, view_id));
-                                    }
-                                }));
+                                let mut debounce = state.debounce.borrow_mut();
+                                if debounce.is_none() {
+                                    *debounce = Some(cx.spawn({
+                                        let state = state.clone();
+                                        |mut cx| async move {
+                                            cx.background().timer(DEBOUNCE_TIMEOUT).await;
+                                            state.visible.set(true);
+                                            cx.update(|cx| cx.notify_view(window_id, view_id));
+                                        }
+                                    }));
+                                }
                             }
+                        } else {
+                            state.visible.set(false);
+                            state.debounce.take();
                         }
-                    } else {
-                        state.visible.set(false);
-                        state.debounce.take();
                     }
-                }
-            })
-            .boxed();
+                })
+                .boxed();
         Self {
             child,
             tooltip,

crates/workspace/src/sidebar.rs 🔗

@@ -68,6 +68,7 @@ pub enum Side {
 
 struct Item {
     icon_path: &'static str,
+    tooltip: String,
     view: Rc<dyn SidebarItemHandle>,
     _subscriptions: [Subscription; 2],
 }
@@ -104,6 +105,7 @@ impl Sidebar {
     pub fn add_item<T: SidebarItem>(
         &mut self,
         icon_path: &'static str,
+        tooltip: String,
         view: ViewHandle<T>,
         cx: &mut ViewContext<Self>,
     ) {
@@ -123,6 +125,7 @@ impl Sidebar {
         ];
         self.items.push(Item {
             icon_path,
+            tooltip,
             view: Rc::new(view),
             _subscriptions: subscriptions,
         });
@@ -239,12 +242,9 @@ impl View for SidebarButtons {
     }
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = &cx
-            .global::<Settings>()
-            .theme
-            .workspace
-            .status_bar
-            .sidebar_buttons;
+        let theme = &cx.global::<Settings>().theme;
+        let tooltip_style = theme.tooltip.clone();
+        let theme = &theme.workspace.status_bar.sidebar_buttons;
         let sidebar = self.sidebar.read(cx);
         let item_style = theme.item;
         let badge_style = theme.badge;
@@ -257,52 +257,56 @@ impl View for SidebarButtons {
         let items = sidebar
             .items
             .iter()
-            .map(|item| (item.icon_path, item.view.clone()))
+            .map(|item| (item.icon_path, item.tooltip.clone(), item.view.clone()))
             .collect::<Vec<_>>();
         Flex::row()
-            .with_children(
-                items
-                    .into_iter()
-                    .enumerate()
-                    .map(|(ix, (icon_path, item_view))| {
-                        MouseEventHandler::new::<Self, _, _>(ix, cx, move |state, cx| {
-                            let is_active = Some(ix) == active_ix;
-                            let style = item_style.style_for(state, is_active);
-                            Stack::new()
-                                .with_child(
-                                    Svg::new(icon_path).with_color(style.icon_color).boxed(),
+            .with_children(items.into_iter().enumerate().map(
+                |(ix, (icon_path, tooltip, item_view))| {
+                    let action = ToggleSidebarItem {
+                        side,
+                        item_index: ix,
+                    };
+                    MouseEventHandler::new::<Self, _, _>(ix, cx, move |state, cx| {
+                        let is_active = Some(ix) == active_ix;
+                        let style = item_style.style_for(state, is_active);
+                        Stack::new()
+                            .with_child(Svg::new(icon_path).with_color(style.icon_color).boxed())
+                            .with_children(if !is_active && item_view.should_show_badge(cx) {
+                                Some(
+                                    Empty::new()
+                                        .collapsed()
+                                        .contained()
+                                        .with_style(badge_style)
+                                        .aligned()
+                                        .bottom()
+                                        .right()
+                                        .boxed(),
                                 )
-                                .with_children(if !is_active && item_view.should_show_badge(cx) {
-                                    Some(
-                                        Empty::new()
-                                            .collapsed()
-                                            .contained()
-                                            .with_style(badge_style)
-                                            .aligned()
-                                            .bottom()
-                                            .right()
-                                            .boxed(),
-                                    )
-                                } else {
-                                    None
-                                })
-                                .constrained()
-                                .with_width(style.icon_size)
-                                .with_height(style.icon_size)
-                                .contained()
-                                .with_style(style.container)
-                                .boxed()
-                        })
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, _, cx| {
-                            cx.dispatch_action(ToggleSidebarItem {
-                                side,
-                                item_index: ix,
+                            } else {
+                                None
                             })
-                        })
-                        .boxed()
-                    }),
-            )
+                            .constrained()
+                            .with_width(style.icon_size)
+                            .with_height(style.icon_size)
+                            .contained()
+                            .with_style(style.container)
+                            .boxed()
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click({
+                        let action = action.clone();
+                        move |_, _, cx| cx.dispatch_action(action.clone())
+                    })
+                    .with_tooltip::<Self, _>(
+                        ix,
+                        tooltip,
+                        Some(Box::new(action)),
+                        tooltip_style.clone(),
+                        cx,
+                    )
+                    .boxed()
+                },
+            ))
             .contained()
             .with_style(group_style)
             .boxed()

crates/workspace/src/workspace.rs 🔗

@@ -1788,7 +1788,7 @@ impl Workspace {
                 Some(self.render_avatar(
                     collaborator.user.avatar.clone()?,
                     collaborator.replica_id,
-                    Some(collaborator.peer_id),
+                    Some((collaborator.peer_id, &collaborator.user.github_login)),
                     theme,
                     cx,
                 ))
@@ -1833,12 +1833,12 @@ impl Workspace {
         &self,
         avatar: Arc<ImageData>,
         replica_id: ReplicaId,
-        peer_id: Option<PeerId>,
+        peer: Option<(PeerId, &str)>,
         theme: &Theme,
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
         let replica_color = theme.editor.replica_selection_style(replica_id).cursor;
-        let is_followed = peer_id.map_or(false, |peer_id| {
+        let is_followed = peer.map_or(false, |(peer_id, _)| {
             self.follower_states_by_leader.contains_key(&peer_id)
         });
         let mut avatar_style = theme.workspace.titlebar.avatar;
@@ -1869,10 +1869,21 @@ impl Workspace {
             .with_margin_left(theme.workspace.titlebar.avatar_margin)
             .boxed();
 
-        if let Some(peer_id) = peer_id {
+        if let Some((peer_id, peer_github_login)) = peer {
             MouseEventHandler::new::<ToggleFollow, _, _>(replica_id.into(), cx, move |_, _| content)
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(move |_, _, cx| cx.dispatch_action(ToggleFollow(peer_id)))
+                .with_tooltip::<ToggleFollow, _>(
+                    peer_id.0 as usize,
+                    if is_followed {
+                        format!("Unfollow {}", peer_github_login)
+                    } else {
+                        format!("Follow {}", peer_github_login)
+                    },
+                    Some(Box::new(FollowNextCollaborator)),
+                    theme.tooltip.clone(),
+                    cx,
+                )
                 .boxed()
         } else {
             content

crates/zed/src/zed.rs 🔗

@@ -191,10 +191,20 @@ pub fn initialize_workspace(
     });
 
     workspace.left_sidebar().update(cx, |sidebar, cx| {
-        sidebar.add_item("icons/folder-tree-solid-14.svg", project_panel.into(), cx)
+        sidebar.add_item(
+            "icons/folder-tree-solid-14.svg",
+            "Project Panel".to_string(),
+            project_panel.into(),
+            cx,
+        )
     });
     workspace.right_sidebar().update(cx, |sidebar, cx| {
-        sidebar.add_item("icons/contacts-solid-14.svg", contact_panel.into(), cx)
+        sidebar.add_item(
+            "icons/contacts-solid-14.svg",
+            "Contacts Panel".to_string(),
+            contact_panel.into(),
+            cx,
+        )
     });
 
     let diagnostic_summary =