From 189b5ff3a27bea0f07e64787d9f421eefe273d3f Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:29:44 -0300 Subject: [PATCH] ui: Make docs aside in pickers and context menu render centered to its trigger (#45868) 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 --- 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 ++++-- .../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(-) diff --git a/crates/agent_ui/src/acp/config_options.rs b/crates/agent_ui/src/acp/config_options.rs index f2e1aefc3f9c37cfaa79257aed2e6a7312ac1d22..3a010d2e2e05d3fcedc3888a6c025307f3426b58 100644 --- a/crates/agent_ui/src/acp/config_options.rs +++ b/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 { + self.selected_description.as_ref().map(|(ix, _, _)| *ix) + } } fn extract_options( diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs index 22af75a6e96edc4f597819e04e2e84b80ba0417a..15e2a3b768c74d4a56d906fada5fd0e992ffc8e3 100644 --- a/crates/agent_ui/src/acp/mode_selector.rs +++ b/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 |_| { diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index c8ed636e4fead9f10e4763904e17d36a5eb6bbb6..b3d5c14a6cfea50aa36444c02e1192ab9db6c54a 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/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 { + self.selected_description.as_ref().map(|(ix, _, _)| *ix) + } + fn render_footer( &self, _window: &mut Window, diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 327d2c67e2d5e87e67935ecdfa7fb6cd41acbcb5..9330fdb292ad4ed956eaf5c767913c2fa54e2478 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/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>, filtered_entries: Vec, selected_index: usize, + hovered_index: Option, query: String, cancel: Option>, 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 { 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 { + 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, diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index ca9864753cc759409914b42daa5de6b517a473ba..f6a6cf958e2e57aaec4618578c85d8da95632592 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/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() }) diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 7775586bf19539e13adc6b9df6d92914be6b7f21..953ef0444681aa0af3aae53e752c0a6a35408861 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/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()), ) }), diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 2da40b5bf4b47651df7236b0decb25fac67a3b1b..4d70918c573064ca24b0fdfa5add0775b275fd44 100644 --- a/crates/picker/src/picker.rs +++ b/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 { /// /// 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>>>, + /// Bounds tracking for items (for aside positioning) - maps item index to bounds + item_bounds: Rc>>>, } #[derive(Debug, Default, Clone, Copy, PartialEq)] @@ -243,6 +249,13 @@ pub trait PickerDelegate: Sized + 'static { ) -> Option { 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 { + None + } } impl Focusable for Picker { @@ -329,6 +342,8 @@ impl Picker { 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 Picker { cx: &mut Context, ix: usize, ) -> impl IntoElement + use { + 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 Render for Picker { 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 Render for Picker { }; 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() diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index bcde8ee74508483e84feded05753d2a0355033f4..d0164dc939213fb02185ac73cb2c9adbe9ea6de4 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/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, + main_menu: Option>, + main_menu_observed_bounds: Rc>>>, + // Docs aide-related fields + documentation_aside: Option<(usize, DocumentationAside)>, + aside_trigger_bounds: Rc>>>, // Submenu-related fields - parent_menu: Option>, submenu_state: SubmenuState, hover_target: HoverTarget, submenu_safety_threshold_x: Option, - submenu_observed_bounds: Rc>>>, submenu_trigger_bounds: Rc>>>, submenu_trigger_mouse_down: bool, ignore_blur_until: Option, @@ -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 AnyElement>, } impl DocumentationAside { - pub fn new( - side: DocumentationSide, - edge: DocumentationEdge, - render: Rc AnyElement>, - ) -> Self { - Self { side, edge, render } + pub fn new(side: DocumentationSide, render: Rc 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) { - 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, ) { - 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> { - 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, ) -> 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, 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)) }) diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 2a52cc697249cb1f8eb280a48c89ff5aadf6fd85..244d90b02bdfc4dfa70ff367282b2744837f1fa5 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/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) }