ui: Make docs aside in pickers and context menu render centered to its trigger (#45868)

Danilo Leal created

This has been something we've wanted to do for a long time since docs
aside thus far have been hard-anchored either at the top or bottom of
the container that their trigger is sitting in. This PR introduces the
change so that they're centered with their trigger, regardless of
whether it's on a context menu or picker, by including a canvas element
in both the container and the trigger to calculate where the docs aside
should precisely sit on. Here's the result:


https://github.com/user-attachments/assets/8147ad05-1927-4353-991d-405631de67d0

Note that at the moment, docs aside are only visible through _hovering_,
and ideally, they should be available on both hover and selection
(keyboard nav). But I'll leave that for later.

Release Notes:

- N/A

Change summary

crates/agent_ui/src/acp/config_options.rs               |   5 
crates/agent_ui/src/acp/mode_selector.rs                |   6 
crates/agent_ui/src/acp/model_selector.rs               |   7 
crates/agent_ui/src/profile_selector.rs                 |  59 ++-
crates/edit_prediction_ui/src/edit_prediction_button.rs |  14 
crates/language_tools/src/lsp_button.rs                 |   5 
crates/picker/src/picker.rs                             | 104 +++++-
crates/ui/src/components/context_menu.rs                | 176 ++++++----
crates/zed/src/zed/quick_action_bar.rs                  |   8 
9 files changed, 259 insertions(+), 125 deletions(-)

Detailed changes

crates/agent_ui/src/acp/config_options.rs 🔗

@@ -593,7 +593,6 @@ impl PickerDelegate for ConfigOptionPickerDelegate {
 
                 ui::DocumentationAside::new(
                     ui::DocumentationSide::Left,
-                    ui::DocumentationEdge::Top,
                     Rc::new(move |_| {
                         v_flex()
                             .gap_1()
@@ -604,6 +603,10 @@ impl PickerDelegate for ConfigOptionPickerDelegate {
                 )
             })
     }
+
+    fn documentation_aside_index(&self) -> Option<usize> {
+        self.selected_description.as_ref().map(|(ix, _, _)| *ix)
+    }
 }
 
 fn extract_options(

crates/agent_ui/src/acp/mode_selector.rs 🔗

@@ -7,8 +7,8 @@ use gpui::{Context, Entity, FocusHandle, WeakEntity, Window, prelude::*};
 use settings::Settings as _;
 use std::{rc::Rc, sync::Arc};
 use ui::{
-    Button, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, KeyBinding,
-    PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
+    Button, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
+    PopoverMenuHandle, Tooltip, prelude::*,
 };
 
 use crate::{CycleModeSelector, ToggleProfileSelector, ui::HoldForDefault};
@@ -105,7 +105,7 @@ impl ModeSelector {
                     .toggleable(IconPosition::End, is_selected);
 
                 let entry = if let Some(description) = &mode.description {
-                    entry.documentation_aside(side, DocumentationEdge::Bottom, {
+                    entry.documentation_aside(side, {
                         let description = description.clone();
 
                         move |_| {

crates/agent_ui/src/acp/model_selector.rs 🔗

@@ -16,7 +16,7 @@ use itertools::Itertools;
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
 use settings::SettingsStore;
-use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*};
+use ui::{DocumentationAside, DocumentationSide, IntoElement, prelude::*};
 use util::ResultExt;
 use zed_actions::agent::OpenSettings;
 
@@ -388,7 +388,6 @@ impl PickerDelegate for AcpModelPickerDelegate {
 
                 DocumentationAside::new(
                     DocumentationSide::Left,
-                    DocumentationEdge::Top,
                     Rc::new(move |_| {
                         v_flex()
                             .gap_1()
@@ -400,6 +399,10 @@ impl PickerDelegate for AcpModelPickerDelegate {
             })
     }
 
+    fn documentation_aside_index(&self) -> Option<usize> {
+        self.selected_description.as_ref().map(|(ix, _, _)| *ix)
+    }
+
     fn render_footer(
         &self,
         _window: &mut Window,

crates/agent_ui/src/profile_selector.rs 🔗

@@ -15,8 +15,8 @@ use std::{
     sync::{Arc, atomic::AtomicBool},
 };
 use ui::{
-    DocumentationAside, DocumentationEdge, DocumentationSide, HighlightedLabel, KeyBinding,
-    LabelSize, ListItem, ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
+    DocumentationAside, DocumentationSide, HighlightedLabel, KeyBinding, LabelSize, ListItem,
+    ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
 };
 
 /// Trait for types that can provide and manage agent profiles
@@ -244,6 +244,7 @@ pub(crate) struct ProfilePickerDelegate {
     string_candidates: Arc<Vec<StringMatchCandidate>>,
     filtered_entries: Vec<ProfilePickerEntry>,
     selected_index: usize,
+    hovered_index: Option<usize>,
     query: String,
     cancel: Option<Arc<AtomicBool>>,
     focus_handle: FocusHandle,
@@ -270,6 +271,7 @@ impl ProfilePickerDelegate {
             string_candidates,
             filtered_entries,
             selected_index: 0,
+            hovered_index: None,
             query: String::new(),
             cancel: None,
             focus_handle,
@@ -578,23 +580,38 @@ impl PickerDelegate for ProfilePickerDelegate {
                 let candidate = self.candidates.get(entry.candidate_index)?;
                 let active_id = self.provider.profile_id(cx);
                 let is_active = active_id == candidate.id;
+                let has_documentation = Self::documentation(candidate).is_some();
 
                 Some(
-                    ListItem::new(candidate.id.0.clone())
-                        .inset(true)
-                        .spacing(ListItemSpacing::Sparse)
-                        .toggle_state(selected)
-                        .child(HighlightedLabel::new(
-                            candidate.name.clone(),
-                            entry.positions.clone(),
-                        ))
-                        .when(is_active, |this| {
-                            this.end_slot(
-                                div()
-                                    .pr_2()
-                                    .child(Icon::new(IconName::Check).color(Color::Accent)),
-                            )
+                    div()
+                        .id(("profile-picker-item", ix))
+                        .when(has_documentation, |this| {
+                            this.on_hover(cx.listener(move |picker, hovered, _, cx| {
+                                if *hovered {
+                                    picker.delegate.hovered_index = Some(ix);
+                                } else if picker.delegate.hovered_index == Some(ix) {
+                                    picker.delegate.hovered_index = None;
+                                }
+                                cx.notify();
+                            }))
                         })
+                        .child(
+                            ListItem::new(candidate.id.0.clone())
+                                .inset(true)
+                                .spacing(ListItemSpacing::Sparse)
+                                .toggle_state(selected)
+                                .child(HighlightedLabel::new(
+                                    candidate.name.clone(),
+                                    entry.positions.clone(),
+                                ))
+                                .when(is_active, |this| {
+                                    this.end_slot(
+                                        div()
+                                            .pr_2()
+                                            .child(Icon::new(IconName::Check).color(Color::Accent)),
+                                    )
+                                }),
+                        )
                         .into_any_element(),
                 )
             }
@@ -608,7 +625,8 @@ impl PickerDelegate for ProfilePickerDelegate {
     ) -> Option<DocumentationAside> {
         use std::rc::Rc;
 
-        let entry = match self.filtered_entries.get(self.selected_index)? {
+        let hovered_index = self.hovered_index?;
+        let entry = match self.filtered_entries.get(hovered_index)? {
             ProfilePickerEntry::Profile(entry) => entry,
             ProfilePickerEntry::Header(_) => return None,
         };
@@ -626,11 +644,14 @@ impl PickerDelegate for ProfilePickerDelegate {
 
         Some(DocumentationAside {
             side,
-            edge: DocumentationEdge::Top,
             render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()),
         })
     }
 
+    fn documentation_aside_index(&self) -> Option<usize> {
+        self.hovered_index
+    }
+
     fn render_footer(
         &self,
         _: &mut Window,
@@ -718,6 +739,7 @@ mod tests {
                 string_candidates: Arc::new(Vec::new()),
                 filtered_entries: Vec::new(),
                 selected_index: 0,
+                hovered_index: None,
                 query: String::new(),
                 cancel: None,
                 focus_handle,
@@ -752,6 +774,7 @@ mod tests {
                 background: cx.background_executor().clone(),
                 candidates,
                 string_candidates: Arc::new(Vec::new()),
+                hovered_index: None,
                 filtered_entries: vec![
                     ProfilePickerEntry::Profile(ProfileMatchEntry {
                         candidate_index: 0,

crates/edit_prediction_ui/src/edit_prediction_button.rs 🔗

@@ -36,8 +36,8 @@ use std::{
 };
 use supermaven::{AccountStatus, Supermaven};
 use ui::{
-    Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton,
-    IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
+    Clickable, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconButtonShape,
+    Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
 };
 use util::ResultExt as _;
 use workspace::{
@@ -680,7 +680,7 @@ impl EditPredictionButton {
                     menu = menu.item(
                         entry
                             .disabled(true)
-                            .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_cx| {
+                            .documentation_aside(DocumentationSide::Left, move |_cx| {
                                 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
                                     .into_any_element()
                             })
@@ -726,7 +726,7 @@ impl EditPredictionButton {
                 .item(
                     ContextMenuEntry::new("Eager")
                         .toggleable(IconPosition::Start, eager_mode)
-                        .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
+                        .documentation_aside(DocumentationSide::Left, move |_| {
                             Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
                         })
                         .handler({
@@ -739,7 +739,7 @@ impl EditPredictionButton {
                 .item(
                     ContextMenuEntry::new("Subtle")
                         .toggleable(IconPosition::Start, subtle_mode)
-                        .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
+                        .documentation_aside(DocumentationSide::Left, move |_| {
                             Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
                         })
                         .handler({
@@ -778,7 +778,7 @@ impl EditPredictionButton {
                             .toggleable(IconPosition::Start, data_collection.is_enabled())
                             .icon(icon_name)
                             .icon_color(icon_color)
-                            .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
+                            .documentation_aside(DocumentationSide::Left, move |cx| {
                                 let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
                                     (true, true) => (
                                         "Project identified as open source, and you're sharing data.",
@@ -862,7 +862,7 @@ impl EditPredictionButton {
             ContextMenuEntry::new("Configure Excluded Files")
                 .icon(IconName::LockOutlined)
                 .icon_color(Color::Muted)
-                .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| {
+                .documentation_aside(DocumentationSide::Left, |_| {
                     Label::new(indoc!{"
                         Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
                 })

crates/language_tools/src/lsp_button.rs 🔗

@@ -17,8 +17,8 @@ use project::{
 };
 use settings::{Settings as _, SettingsStore};
 use ui::{
-    Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationEdge,
-    DocumentationSide, Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, Window, prelude::*,
+    Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide,
+    Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, Window, prelude::*,
 };
 
 use util::{ResultExt, rel_path::RelPath};
@@ -384,7 +384,6 @@ impl LanguageServerState {
                 tooltip_text.map(|tooltip_text| {
                     DocumentationAside::new(
                         DocumentationSide::Right,
-                        DocumentationEdge::Top,
                         Rc::new(move |_| Label::new(tooltip_text.clone()).into_any_element()),
                     )
                 }),

crates/picker/src/picker.rs 🔗

@@ -9,19 +9,21 @@ use editor::{
     scroll::Autoscroll,
 };
 use gpui::{
-    Action, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
-    Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Render,
-    ScrollStrategy, Task, UniformListScrollHandle, Window, actions, div, list, prelude::*,
-    uniform_list,
+    Action, AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, Entity, EventEmitter,
+    FocusHandle, Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent,
+    Pixels, Render, ScrollStrategy, Task, UniformListScrollHandle, Window, actions, canvas, div,
+    list, prelude::*, uniform_list,
 };
 use head::Head;
 use schemars::JsonSchema;
 use serde::Deserialize;
-use std::{ops::Range, sync::Arc, time::Duration};
+use std::{
+    cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration,
+};
 use theme::ThemeSettings;
 use ui::{
-    Color, Divider, DocumentationAside, DocumentationEdge, DocumentationSide, Label, ListItem,
-    ListItemSpacing, ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex,
+    Color, Divider, DocumentationAside, DocumentationSide, Label, ListItem, ListItemSpacing,
+    ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex,
 };
 use workspace::{ModalView, item::Settings};
 
@@ -72,6 +74,10 @@ pub struct Picker<D: PickerDelegate> {
     ///
     /// Set this to `false` when rendering the `Picker` as part of a larger modal.
     is_modal: bool,
+    /// Bounds tracking for the picker container (for aside positioning)
+    picker_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
+    /// Bounds tracking for items (for aside positioning) - maps item index to bounds
+    item_bounds: Rc<RefCell<HashMap<usize, Bounds<Pixels>>>>,
 }
 
 #[derive(Debug, Default, Clone, Copy, PartialEq)]
@@ -243,6 +249,13 @@ pub trait PickerDelegate: Sized + 'static {
     ) -> Option<DocumentationAside> {
         None
     }
+
+    /// Returns the index of the item whose documentation aside should be shown.
+    /// This is used to position the aside relative to that item.
+    /// Typically this is the hovered item, not necessarily the selected item.
+    fn documentation_aside_index(&self) -> Option<usize> {
+        None
+    }
 }
 
 impl<D: PickerDelegate> Focusable for Picker<D> {
@@ -329,6 +342,8 @@ impl<D: PickerDelegate> Picker<D> {
             max_height: Some(rems(24.).into()),
             show_scrollbar: false,
             is_modal: true,
+            picker_bounds: Rc::new(Cell::new(None)),
+            item_bounds: Rc::new(RefCell::new(HashMap::default())),
         };
         this.update_matches("".to_string(), window, cx);
         // give the delegate 4ms to render the first set of suggestions.
@@ -750,9 +765,23 @@ impl<D: PickerDelegate> Picker<D> {
         cx: &mut Context<Self>,
         ix: usize,
     ) -> impl IntoElement + use<D> {
+        let item_bounds = self.item_bounds.clone();
+
         div()
             .id(("item", ix))
             .cursor_pointer()
+            .child(
+                canvas(
+                    move |bounds, _window, _cx| {
+                        item_bounds.borrow_mut().insert(ix, bounds);
+                    },
+                    |_bounds, _state, _window, _cx| {},
+                )
+                .size_full()
+                .absolute()
+                .top_0()
+                .left_0(),
+            )
             .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| {
                 this.handle_click(ix, event.modifiers().secondary(), window, cx)
             }))
@@ -847,11 +876,24 @@ impl<D: PickerDelegate> Render for Picker<D> {
         let aside = self.delegate.documentation_aside(window, cx);
 
         let editor_position = self.delegate.editor_position();
+        let picker_bounds = self.picker_bounds.clone();
         let menu = v_flex()
             .key_context("Picker")
             .size_full()
             .when_some(self.width, |el, width| el.w(width))
             .overflow_hidden()
+            .child(
+                canvas(
+                    move |bounds, _window, _cx| {
+                        picker_bounds.set(Some(bounds));
+                    },
+                    |_bounds, _state, _window, _cx| {},
+                )
+                .size_full()
+                .absolute()
+                .top_0()
+                .left_0(),
+            )
             // This is a bit of a hack to remove the modal styling when we're rendering the `Picker`
             // as a part of a modal rather than the entire modal.
             //
@@ -949,21 +991,39 @@ impl<D: PickerDelegate> Render for Picker<D> {
         };
 
         if is_wide_window {
-            div().relative().child(menu).child(
-                h_flex()
-                    .absolute()
-                    .when(aside.side == DocumentationSide::Left, |this| {
-                        this.right_full().mr_1()
-                    })
-                    .when(aside.side == DocumentationSide::Right, |this| {
-                        this.left_full().ml_1()
-                    })
-                    .when(aside.edge == DocumentationEdge::Top, |this| this.top_0())
-                    .when(aside.edge == DocumentationEdge::Bottom, |this| {
-                        this.bottom_0()
-                    })
-                    .child(render_aside(aside, cx)),
-            )
+            let aside_index = self.delegate.documentation_aside_index();
+            let picker_bounds = self.picker_bounds.get();
+            let item_bounds =
+                aside_index.and_then(|ix| self.item_bounds.borrow().get(&ix).copied());
+
+            let item_position = match (picker_bounds, item_bounds) {
+                (Some(picker_bounds), Some(item_bounds)) => {
+                    let relative_top = item_bounds.origin.y - picker_bounds.origin.y;
+                    let height = item_bounds.size.height;
+                    Some((relative_top, height))
+                }
+                _ => None,
+            };
+
+            div()
+                .relative()
+                .child(menu)
+                // Only render the aside once we have bounds to avoid flicker
+                .when_some(item_position, |this, (top, height)| {
+                    this.child(
+                        h_flex()
+                            .absolute()
+                            .when(aside.side == DocumentationSide::Left, |el| {
+                                el.right_full().mr_1()
+                            })
+                            .when(aside.side == DocumentationSide::Right, |el| {
+                                el.left_full().ml_1()
+                            })
+                            .top(top)
+                            .h(height)
+                            .child(render_aside(aside, cx)),
+                    )
+                })
         } else {
             v_flex()
                 .w_full()

crates/ui/src/components/context_menu.rs 🔗

@@ -10,7 +10,8 @@ use gpui::{
 use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious};
 use settings::Settings;
 use std::{
-    cell::Cell,
+    cell::{Cell, RefCell},
+    collections::HashMap,
     rc::Rc,
     time::{Duration, Instant},
 };
@@ -182,12 +183,10 @@ impl ContextMenuEntry {
     pub fn documentation_aside(
         mut self,
         side: DocumentationSide,
-        edge: DocumentationEdge,
         render: impl Fn(&mut App) -> AnyElement + 'static,
     ) -> Self {
         self.documentation_aside = Some(DocumentationAside {
             side,
-            edge,
             render: Rc::new(render),
         });
 
@@ -215,14 +214,16 @@ pub struct ContextMenu {
     key_context: SharedString,
     _on_blur_subscription: Subscription,
     keep_open_on_confirm: bool,
-    documentation_aside: Option<(usize, DocumentationAside)>,
     fixed_width: Option<DefiniteLength>,
+    main_menu: Option<Entity<ContextMenu>>,
+    main_menu_observed_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
+    // Docs aide-related fields
+    documentation_aside: Option<(usize, DocumentationAside)>,
+    aside_trigger_bounds: Rc<RefCell<HashMap<usize, Bounds<Pixels>>>>,
     // Submenu-related fields
-    parent_menu: Option<Entity<ContextMenu>>,
     submenu_state: SubmenuState,
     hover_target: HoverTarget,
     submenu_safety_threshold_x: Option<Pixels>,
-    submenu_observed_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
     submenu_trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
     submenu_trigger_mouse_down: bool,
     ignore_blur_until: Option<Instant>,
@@ -234,27 +235,15 @@ pub enum DocumentationSide {
     Right,
 }
 
-#[derive(Copy, Default, Clone, PartialEq, Eq)]
-pub enum DocumentationEdge {
-    #[default]
-    Top,
-    Bottom,
-}
-
 #[derive(Clone)]
 pub struct DocumentationAside {
     pub side: DocumentationSide,
-    pub edge: DocumentationEdge,
     pub render: Rc<dyn Fn(&mut App) -> AnyElement>,
 }
 
 impl DocumentationAside {
-    pub fn new(
-        side: DocumentationSide,
-        edge: DocumentationEdge,
-        render: Rc<dyn Fn(&mut App) -> AnyElement>,
-    ) -> Self {
-        Self { side, edge, render }
+    pub fn new(side: DocumentationSide, render: Rc<dyn Fn(&mut App) -> AnyElement>) -> Self {
+        Self { side, render }
     }
 }
 
@@ -287,7 +276,7 @@ impl ContextMenu {
                     }
                 }
 
-                if this.parent_menu.is_none() {
+                if this.main_menu.is_none() {
                     if let SubmenuState::Open(open_submenu) = &this.submenu_state {
                         let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone();
                         if submenu_focus.contains_focused(window, cx) {
@@ -314,13 +303,14 @@ impl ContextMenu {
                 key_context: "menu".into(),
                 _on_blur_subscription,
                 keep_open_on_confirm: false,
-                documentation_aside: None,
                 fixed_width: None,
-                parent_menu: None,
+                main_menu: None,
+                main_menu_observed_bounds: Rc::new(Cell::new(None)),
+                documentation_aside: None,
+                aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
                 submenu_state: SubmenuState::Closed,
                 hover_target: HoverTarget::MainMenu,
                 submenu_safety_threshold_x: None,
-                submenu_observed_bounds: Rc::new(Cell::new(None)),
                 submenu_trigger_bounds: Rc::new(Cell::new(None)),
                 submenu_trigger_mouse_down: false,
                 ignore_blur_until: None,
@@ -363,7 +353,7 @@ impl ContextMenu {
                         }
                     }
 
-                    if this.parent_menu.is_none() {
+                    if this.main_menu.is_none() {
                         if let SubmenuState::Open(open_submenu) = &this.submenu_state {
                             let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone();
                             if submenu_focus.contains_focused(window, cx) {
@@ -390,13 +380,14 @@ impl ContextMenu {
                     key_context: "menu".into(),
                     _on_blur_subscription,
                     keep_open_on_confirm: true,
-                    documentation_aside: None,
                     fixed_width: None,
-                    parent_menu: None,
+                    main_menu: None,
+                    main_menu_observed_bounds: Rc::new(Cell::new(None)),
+                    documentation_aside: None,
+                    aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
                     submenu_state: SubmenuState::Closed,
                     hover_target: HoverTarget::MainMenu,
                     submenu_safety_threshold_x: None,
-                    submenu_observed_bounds: Rc::new(Cell::new(None)),
                     submenu_trigger_bounds: Rc::new(Cell::new(None)),
                     submenu_trigger_mouse_down: false,
                     ignore_blur_until: None,
@@ -444,7 +435,7 @@ impl ContextMenu {
                             }
                         }
 
-                        if this.parent_menu.is_none() {
+                        if this.main_menu.is_none() {
                             if let SubmenuState::Open(open_submenu) = &this.submenu_state {
                                 let submenu_focus =
                                     open_submenu.entity.read(cx).focus_handle.clone();
@@ -458,13 +449,14 @@ impl ContextMenu {
                     },
                 ),
                 keep_open_on_confirm: false,
-                documentation_aside: None,
                 fixed_width: None,
-                parent_menu: None,
+                main_menu: None,
+                main_menu_observed_bounds: Rc::new(Cell::new(None)),
+                documentation_aside: None,
+                aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
                 submenu_state: SubmenuState::Closed,
                 hover_target: HoverTarget::MainMenu,
                 submenu_safety_threshold_x: None,
-                submenu_observed_bounds: Rc::new(Cell::new(None)),
                 submenu_trigger_bounds: Rc::new(Cell::new(None)),
                 submenu_trigger_mouse_down: false,
                 ignore_blur_until: None,
@@ -837,7 +829,7 @@ impl ContextMenu {
             (handler)(context, window, cx)
         }
 
-        if self.parent_menu.is_some() && !self.keep_open_on_confirm {
+        if self.main_menu.is_some() && !self.keep_open_on_confirm {
             self.clicked = true;
         }
 
@@ -849,11 +841,11 @@ impl ContextMenu {
     }
 
     pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
-        if self.parent_menu.is_some() {
+        if self.main_menu.is_some() {
             cx.emit(DismissEvent);
 
             // Restore keyboard focus to the parent menu so arrow keys / Escape / Enter work again.
-            if let Some(parent) = &self.parent_menu {
+            if let Some(parent) = &self.main_menu {
                 let parent_focus = parent.read(cx).focus_handle.clone();
 
                 parent.update(cx, |parent, _cx| {
@@ -986,11 +978,11 @@ impl ContextMenu {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.parent_menu.is_none() {
+        if self.main_menu.is_none() {
             return;
         }
 
-        if let Some(parent) = &self.parent_menu {
+        if let Some(parent) = &self.main_menu {
             let parent_clone = parent.clone();
 
             let parent_focus = parent.read(cx).focus_handle.clone();
@@ -1090,13 +1082,14 @@ impl ContextMenu {
                 key_context: "menu".into(),
                 _on_blur_subscription,
                 keep_open_on_confirm: false,
-                documentation_aside: None,
                 fixed_width: None,
-                parent_menu: Some(parent_entity),
+                documentation_aside: None,
+                aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())),
+                main_menu: Some(parent_entity),
+                main_menu_observed_bounds: Rc::new(Cell::new(None)),
                 submenu_state: SubmenuState::Closed,
                 hover_target: HoverTarget::MainMenu,
                 submenu_safety_threshold_x: None,
-                submenu_observed_bounds: Rc::new(Cell::new(None)),
                 submenu_trigger_bounds: Rc::new(Cell::new(None)),
                 submenu_trigger_mouse_down: false,
                 ignore_blur_until: None,
@@ -1111,7 +1104,7 @@ impl ContextMenu {
         self.submenu_state = SubmenuState::Closed;
         self.hover_target = HoverTarget::MainMenu;
         self.submenu_safety_threshold_x = None;
-        self.submenu_observed_bounds.set(None);
+        self.main_menu_observed_bounds.set(None);
         self.submenu_trigger_bounds.set(None);
 
         if clear_selection {
@@ -1142,7 +1135,7 @@ impl ContextMenu {
 
         // If we're switching from one submenu item to another, throw away any previously-captured
         // offset so we don't reuse a stale position.
-        self.submenu_observed_bounds.set(None);
+        self.main_menu_observed_bounds.set(None);
         self.submenu_trigger_bounds.set(None);
 
         self.submenu_safety_threshold_x = None;
@@ -1265,6 +1258,7 @@ impl ContextMenu {
                 let handler = handler.clone();
                 let menu = cx.entity().downgrade();
                 let selectable = *selectable;
+                let aside_trigger_bounds = self.aside_trigger_bounds.clone();
 
                 div()
                     .id(("context-menu-child", ix))
@@ -1280,6 +1274,23 @@ impl ContextMenu {
                             cx.notify();
                         }))
                     })
+                    .when(documentation_aside.is_some(), |this| {
+                        this.child(
+                            canvas(
+                                {
+                                    let aside_trigger_bounds = aside_trigger_bounds.clone();
+                                    move |bounds, _window, _cx| {
+                                        aside_trigger_bounds.borrow_mut().insert(ix, bounds);
+                                    }
+                                },
+                                |_bounds, _state, _window, _cx| {},
+                            )
+                            .size_full()
+                            .absolute()
+                            .top_0()
+                            .left_0(),
+                        )
+                    })
                     .child(
                         ListItem::new(ix)
                             .inset(true)
@@ -1453,7 +1464,7 @@ impl ContextMenu {
     }
 
     fn padded_submenu_bounds(&self) -> Option<Bounds<Pixels>> {
-        let bounds = self.submenu_observed_bounds.get()?;
+        let bounds = self.main_menu_observed_bounds.get()?;
         Some(Bounds {
             origin: Point {
                 x: bounds.origin.x - px(50.0),
@@ -1473,7 +1484,7 @@ impl ContextMenu {
         offset: Pixels,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let bounds_cell = self.submenu_observed_bounds.clone();
+        let bounds_cell = self.main_menu_observed_bounds.clone();
         let canvas = canvas(
             {
                 move |bounds, _window, _cx| {
@@ -1618,6 +1629,8 @@ impl ContextMenu {
                 .into_any_element()
         };
 
+        let aside_trigger_bounds = self.aside_trigger_bounds.clone();
+
         div()
             .id(("context-menu-child", ix))
             .when_some(documentation_aside.clone(), |this, documentation_aside| {
@@ -1631,13 +1644,30 @@ impl ContextMenu {
                         cx.notify();
                     }))
             })
+            .when(documentation_aside.is_some(), |this| {
+                this.child(
+                    canvas(
+                        {
+                            let aside_trigger_bounds = aside_trigger_bounds.clone();
+                            move |bounds, _window, _cx| {
+                                aside_trigger_bounds.borrow_mut().insert(ix, bounds);
+                            }
+                        },
+                        |_bounds, _state, _window, _cx| {},
+                    )
+                    .size_full()
+                    .absolute()
+                    .top_0()
+                    .left_0(),
+                )
+            })
             .child(
                 ListItem::new(ix)
                     .group_name("label_container")
                     .inset(true)
                     .disabled(*disabled)
                     .toggle_state(Some(ix) == self.selected_index)
-                    .when(self.parent_menu.is_none() && !*disabled, |item| {
+                    .when(self.main_menu.is_none() && !*disabled, |item| {
                         item.on_hover(cx.listener(move |this, hovered, window, cx| {
                             if *hovered {
                                 this.clear_selected();
@@ -1652,7 +1682,7 @@ impl ContextMenu {
                             }
                         }))
                     })
-                    .when(self.parent_menu.is_some(), |item| {
+                    .when(self.main_menu.is_some(), |item| {
                         item.on_click(cx.listener(move |this, _, window, cx| {
                             if matches!(
                                 &this.submenu_state,
@@ -1680,7 +1710,7 @@ impl ContextMenu {
                                     cx.notify();
                                 }
 
-                                if let Some(parent) = &this.parent_menu {
+                                if let Some(parent) = &this.main_menu {
                                     let mouse_pos = window.mouse_position();
                                     let parent_clone = parent.clone();
 
@@ -1857,7 +1887,7 @@ impl Render for ContextMenu {
                 let is_initializing = open_submenu.offset.is_none();
 
                 let computed_offset = if is_initializing {
-                    let menu_bounds = self.submenu_observed_bounds.get();
+                    let menu_bounds = self.main_menu_observed_bounds.get();
                     let trigger_bounds = open_submenu
                         .trigger_bounds
                         .or_else(|| self.submenu_trigger_bounds.get());
@@ -1900,7 +1930,7 @@ impl Render for ContextMenu {
         };
 
         let render_menu = |cx: &mut Context<Self>, window: &mut Window| {
-            let bounds_cell = self.submenu_observed_bounds.clone();
+            let bounds_cell = self.main_menu_observed_bounds.clone();
             let menu_bounds_measure = canvas(
                 {
                     move |bounds, _window, _cx| {
@@ -1958,7 +1988,7 @@ impl Render for ContextMenu {
                                     }
                                 }
 
-                                if let Some(parent) = &this.parent_menu {
+                                if let Some(parent) = &this.main_menu {
                                     let overridden_by_parent_trigger = parent
                                         .read(cx)
                                         .submenu_trigger_bounds
@@ -2007,24 +2037,40 @@ impl Render for ContextMenu {
         }
 
         if is_wide_window {
+            let menu_bounds = self.main_menu_observed_bounds.get();
+            let trigger_bounds = self
+                .documentation_aside
+                .as_ref()
+                .and_then(|(ix, _)| self.aside_trigger_bounds.borrow().get(ix).copied());
+
+            let trigger_position = match (menu_bounds, trigger_bounds) {
+                (Some(menu_bounds), Some(trigger_bounds)) => {
+                    let relative_top = trigger_bounds.origin.y - menu_bounds.origin.y;
+                    let height = trigger_bounds.size.height;
+                    Some((relative_top, height))
+                }
+                _ => None,
+            };
+
             div()
                 .relative()
                 .child(render_menu(cx, window))
-                .children(aside.map(|(_item_index, aside)| {
-                    h_flex()
-                        .absolute()
-                        .when(aside.side == DocumentationSide::Left, |this| {
-                            this.right_full().mr_1()
-                        })
-                        .when(aside.side == DocumentationSide::Right, |this| {
-                            this.left_full().ml_1()
-                        })
-                        .when(aside.edge == DocumentationEdge::Top, |this| this.top_0())
-                        .when(aside.edge == DocumentationEdge::Bottom, |this| {
-                            this.bottom_0()
-                        })
-                        .child(render_aside(aside, cx))
-                }))
+                // Only render the aside once we have trigger bounds to avoid flicker.
+                .when_some(trigger_position, |this, (top, height)| {
+                    this.children(aside.map(|(_, aside)| {
+                        h_flex()
+                            .absolute()
+                            .when(aside.side == DocumentationSide::Left, |el| {
+                                el.right_full().mr_1()
+                            })
+                            .when(aside.side == DocumentationSide::Right, |el| {
+                                el.left_full().ml_1()
+                            })
+                            .top(top)
+                            .h(height)
+                            .child(render_aside(aside, cx))
+                    }))
+                })
                 .when_some(submenu_container, |this, (ix, submenu, offset)| {
                     this.child(self.render_submenu_container(ix, submenu, offset, cx))
                 })

crates/zed/src/zed/quick_action_bar.rs 🔗

@@ -19,8 +19,8 @@ use project::{DisableAiSettings, project_settings::DiagnosticSeverity};
 use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, SettingsStore};
 use ui::{
-    ButtonStyle, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton,
-    IconName, IconSize, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
+    ButtonStyle, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconName, IconSize,
+    PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
 };
 use vim_mode_setting::{HelixModeSetting, VimModeSetting};
 use workspace::item::ItemBufferKind;
@@ -416,7 +416,7 @@ impl Render for QuickActionBar {
                                         }
                                     });
                                 if !edit_predictions_enabled_at_cursor {
-                                    edit_prediction_entry = edit_prediction_entry.documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| {
+                                    edit_prediction_entry = edit_prediction_entry.documentation_aside(DocumentationSide::Left, |_| {
                                         Label::new("You can't toggle edit predictions for this file as it is within the excluded files list.").into_any_element()
                                     });
                                 }
@@ -467,7 +467,7 @@ impl Render for QuickActionBar {
                                             }
                                         });
                                     if !diagnostics_enabled {
-                                        inline_diagnostics_item = inline_diagnostics_item.disabled(true).documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_|  Label::new("Inline diagnostics are not available until regular diagnostics are enabled.").into_any_element());
+                                        inline_diagnostics_item = inline_diagnostics_item.disabled(true).documentation_aside(DocumentationSide::Left, |_|  Label::new("Inline diagnostics are not available until regular diagnostics are enabled.").into_any_element());
                                     }
                                     menu = menu.item(inline_diagnostics_item)
                                 }