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) }