Translucent/cropped menu when edit prediction is selected + no aside

Displaying the edit prediction inline in the buffer is better than in an aside, as then the user doesn't need to re-locate the edit within the buffer.

Motivation for making the edit menu translucent + cropped + no content is to not obscure the displayed prediction within the buffer, while still making it clear that the menu is open and so pressing `down` will select the first LSP completion in the menu.

Release Notes:

- N/A
This commit is contained in:
Michael Sloan 2025-01-21 17:51:29 -07:00
parent 82cee9e9a4
commit b7c77784a8
4 changed files with 206 additions and 70 deletions

View file

@ -1,8 +1,9 @@
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
div, pulsating_between, px, uniform_list, Animation, AnimationExt, AnyElement, div, pulsating_between, px, uniform_list, Animation, AnimationExt, AnyElement,
BackgroundExecutor, Div, FontWeight, ListSizingBehavior, Model, ScrollStrategy, SharedString, BackgroundExecutor, Div, FontWeight, Hsla, ListSizingBehavior, Model, ScrollStrategy,
Size, StrikethroughStyle, StyledText, UniformListScrollHandle, ViewContext, WeakView, SharedString, Size, StrikethroughStyle, StyledText, TextStyleRefinement,
UniformListScrollHandle, ViewContext, WeakView,
}; };
use language::Buffer; use language::Buffer;
use language::{CodeLabel, Documentation}; use language::{CodeLabel, Documentation};
@ -20,7 +21,7 @@ use std::{
rc::Rc, rc::Rc,
}; };
use task::ResolvedTask; use task::ResolvedTask;
use ui::{prelude::*, Color, IntoElement, ListItem, Pixels, Popover, Styled}; use ui::{prelude::*, Color, IntoElement, ListItem, Pixels, Popover, PopoverElision, Styled};
use util::ResultExt; use util::ResultExt;
use workspace::Workspace; use workspace::Workspace;
@ -30,7 +31,7 @@ use crate::{
render_parsed_markdown, split_words, styled_runs_for_code_label, CodeActionProvider, render_parsed_markdown, split_words, styled_runs_for_code_label, CodeActionProvider,
CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, ResolvedTasks, CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, ResolvedTasks,
}; };
use crate::{AcceptInlineCompletion, InlineCompletionMenuHint, InlineCompletionText}; use crate::{AcceptInlineCompletion, InlineCompletionMenuHint};
pub const MENU_GAP: Pixels = px(4.); pub const MENU_GAP: Pixels = px(4.);
pub const MENU_ASIDE_X_PADDING: Pixels = px(16.); pub const MENU_ASIDE_X_PADDING: Pixels = px(16.);
@ -320,6 +321,19 @@ impl CompletionsMenu {
self.update_selection_index(index, provider, cx); self.update_selection_index(index, provider, cx);
} }
pub fn select_initial_lsp_completion(
&mut self,
provider: Option<&dyn CompletionProvider>,
cx: &mut ViewContext<Editor>,
) {
let index = if self.inline_completion_present() {
1
} else {
0
};
self.update_selection_index(index, provider, cx);
}
fn update_selection_index( fn update_selection_index(
&mut self, &mut self,
match_index: usize, match_index: usize,
@ -353,13 +367,10 @@ impl CompletionsMenu {
pub fn show_inline_completion_hint(&mut self, hint: InlineCompletionMenuHint) { pub fn show_inline_completion_hint(&mut self, hint: InlineCompletionMenuHint) {
let hint = CompletionEntry::InlineCompletionHint(hint); let hint = CompletionEntry::InlineCompletionHint(hint);
let mut entries = self.entries.borrow_mut(); if self.inline_completion_present() {
match entries.first() { self.entries.borrow_mut()[0] = hint;
Some(CompletionEntry::InlineCompletionHint { .. }) => { } else {
entries[0] = hint; self.entries.borrow_mut().insert(0, hint);
}
_ => {
entries.insert(0, hint);
// When `y_flipped`, need to scroll to bring it into view. // When `y_flipped`, need to scroll to bring it into view.
if self.selected_item == 0 { if self.selected_item == 0 {
self.scroll_handle self.scroll_handle
@ -367,6 +378,28 @@ impl CompletionsMenu {
} }
} }
} }
fn inline_completion_present(&self) -> bool {
self.entries.borrow().first().map_or(false, |entry| {
matches!(entry, CompletionEntry::InlineCompletionHint(_))
})
}
fn inline_completion_selected(&self) -> bool {
self.selected_item == 0
&& self.entries.borrow().first().map_or(false, |entry| {
matches!(entry, CompletionEntry::InlineCompletionHint(_))
})
}
fn inline_completion_selected_and_loaded(&self) -> bool {
self.selected_item == 0
&& self.entries.borrow().first().map_or(false, |entry| {
matches!(
entry,
CompletionEntry::InlineCompletionHint(InlineCompletionMenuHint::Loaded { .. })
)
})
} }
pub fn resolve_visible_completions( pub fn resolve_visible_completions(
@ -467,7 +500,7 @@ impl CompletionsMenu {
fn render( fn render(
&self, &self,
style: &EditorStyle, style: &EditorStyle,
max_height_in_lines: u32, mut max_height_in_lines: u32,
y_flipped: bool, y_flipped: bool,
cx: &mut ViewContext<Editor>, cx: &mut ViewContext<Editor>,
) -> AnyElement { ) -> AnyElement {
@ -499,11 +532,25 @@ impl CompletionsMenu {
.map(|(ix, _)| ix); .map(|(ix, _)| ix);
drop(completions); drop(completions);
let translucent = self.inline_completion_selected_and_loaded();
if translucent {
max_height_in_lines = max_height_in_lines.min(2);
}
let selected_item = self.selected_item; let selected_item = self.selected_item;
let completions = self.completions.clone(); let completions = self.completions.clone();
let entries = self.entries.clone(); let entries = self.entries.clone();
let last_rendered_range = self.last_rendered_range.clone(); let last_rendered_range = self.last_rendered_range.clone();
let style = style.clone(); let editor_text_style = if translucent {
// TODO: Opacity of parent should apply (but doesn't).
Rc::new(style.text.clone().refined(TextStyleRefinement {
color: Some(Hsla::transparent_black()),
..Default::default()
}))
} else {
Rc::new(style.text.clone())
};
let editor_syntax_theme = style.syntax.clone();
let list = uniform_list( let list = uniform_list(
cx.view().clone(), cx.view().clone(),
"completions", "completions",
@ -513,16 +560,24 @@ impl CompletionsMenu {
let start_ix = range.start; let start_ix = range.start;
let completions_guard = completions.borrow_mut(); let completions_guard = completions.borrow_mut();
let editor_text_style = editor_text_style.clone();
let editor_syntax_theme = editor_syntax_theme.clone();
entries.borrow()[range] entries.borrow()[range]
.iter() .iter()
.enumerate() .enumerate()
.map(|(ix, mat)| { .map(move |(ix, mat)| {
let item_ix = start_ix + ix; let item_ix = start_ix + ix;
let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
let base_label = h_flex() let base_label = h_flex()
.gap_1() .gap_1()
.child(div().font(buffer_font.clone()).child("Zed AI")) .child(div().font(buffer_font.clone()).child("Zed AI"))
.child(div().px_0p5().child("/").opacity(0.2)); .child(div().px_0p5().child("/").opacity(if translucent {
// TODO: Opacity of parent should apply (but doesn't).
0.
} else {
0.2
}))
.when(translucent, |this| this.opacity(0.));
match mat { match mat {
CompletionEntry::Match(mat) => { CompletionEntry::Match(mat) => {
@ -543,8 +598,12 @@ impl CompletionsMenu {
FontWeight::BOLD.into(), FontWeight::BOLD.into(),
) )
}), }),
styled_runs_for_code_label(&completion.label, &style.syntax) styled_runs_for_code_label(
.map(|(range, mut highlight)| { &completion.label,
&editor_syntax_theme,
)
.map(
|(range, mut highlight)| {
// Ignore font weight for syntax highlighting, as we'll use it // Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches. // for fuzzy matches.
highlight.font_weight = None; highlight.font_weight = None;
@ -561,39 +620,55 @@ impl CompletionsMenu {
} }
(range, highlight) (range, highlight)
}), },
),
); );
let completion_label = let completion_label =
div().when(translucent, |this| this.opacity(0.)).child(
StyledText::new(completion.label.text.clone()) StyledText::new(completion.label.text.clone())
.with_highlights(&style.text, highlights); .with_highlights(&editor_text_style, highlights),
);
let documentation_label = let documentation_label =
if let Some(Documentation::SingleLine(text)) = documentation { if let Some(Documentation::SingleLine(text)) = documentation {
if text.trim().is_empty() { if text.trim().is_empty() {
None None
} else { } else {
Some( Some(
div()
.when(translucent, |this| this.opacity(0.))
.child(
Label::new(text.clone()) Label::new(text.clone())
.ml_4() .ml_4()
.size(LabelSize::Small) .size(LabelSize::Small)
.color(Color::Muted), .color(Color::Muted),
),
) )
} }
} else { } else {
None None
}; };
let color_swatch = completion let color_swatch = completion.color().map(|color| {
.color() div()
.map(|color| div().size_4().bg(color).rounded_sm()); .size_4()
.rounded_sm()
.when(!translucent, |this| this.bg(color))
});
div().min_w(px(220.)).max_w(px(540.)).child( div().min_w(px(220.)).max_w(px(540.)).child(
ListItem::new(mat.candidate_id) ListItem::new(mat.candidate_id)
.inset(true) .inset(true)
.toggle_state(item_ix == selected_item) .toggle_state(item_ix == selected_item)
// TODO: Ideally text would show on mouse hover indicating
// that clicking will cause lsp completions to be shown.
.when(translucent, |this| this.opacity(0.2))
.on_click(cx.listener(move |editor, _event, cx| { .on_click(cx.listener(move |editor, _event, cx| {
cx.stop_propagation(); cx.stop_propagation();
if let Some(task) = editor.confirm_completion( if translucent {
editor
.context_menu_select_initial_lsp_completion(cx);
} else if let Some(task) = editor.confirm_completion(
&ConfirmCompletion { &ConfirmCompletion {
item_ix: Some(item_ix), item_ix: Some(item_ix),
}, },
@ -604,7 +679,7 @@ impl CompletionsMenu {
})) }))
.start_slot::<Div>(color_swatch) .start_slot::<Div>(color_swatch)
.child(h_flex().overflow_hidden().child(completion_label)) .child(h_flex().overflow_hidden().child(completion_label))
.end_slot::<Label>(documentation_label), .end_slot::<Div>(documentation_label),
) )
} }
CompletionEntry::InlineCompletionHint( CompletionEntry::InlineCompletionHint(
@ -617,7 +692,7 @@ impl CompletionsMenu {
.child( .child(
base_label.child( base_label.child(
StyledText::new(hint.label()) StyledText::new(hint.label())
.with_highlights(&style.text, None), .with_highlights(&editor_text_style, None),
), ),
), ),
), ),
@ -629,19 +704,23 @@ impl CompletionsMenu {
.toggle_state(item_ix == selected_item) .toggle_state(item_ix == selected_item)
.start_slot(Icon::new(IconName::ZedPredict)) .start_slot(Icon::new(IconName::ZedPredict))
.child(base_label.child({ .child(base_label.child({
let text_style = style.text.clone();
StyledText::new(hint.label()) StyledText::new(hint.label())
.with_highlights(&text_style, None) .with_highlights(&editor_text_style, None)
.with_animation( .with_animation(
"pulsating-label", "pulsating-label",
Animation::new(Duration::from_secs(1)) Animation::new(Duration::from_secs(1))
.repeat() .repeat()
.with_easing(pulsating_between(0.4, 0.8)), .with_easing(pulsating_between(0.4, 0.8)),
{
let editor_text_style =
editor_text_style.clone();
move |text, delta| { move |text, delta| {
let mut text_style = text_style.clone(); let mut text_style =
(*editor_text_style).clone();
text_style.color = text_style.color =
text_style.color.opacity(delta); text_style.color.opacity(delta);
text.with_highlights(&text_style, None) text.with_highlights(&text_style, None)
}
}, },
) )
})), })),
@ -656,7 +735,7 @@ impl CompletionsMenu {
.child( .child(
base_label.child( base_label.child(
StyledText::new(hint.label()) StyledText::new(hint.label())
.with_highlights(&style.text, None), .with_highlights(&editor_text_style, None),
), ),
) )
.on_click(cx.listener(move |editor, _event, cx| { .on_click(cx.listener(move |editor, _event, cx| {
@ -664,18 +743,23 @@ impl CompletionsMenu {
editor.toggle_zed_predict_tos(cx); editor.toggle_zed_predict_tos(cx);
})), })),
), ),
CompletionEntry::InlineCompletionHint( CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::Loaded { .. }, hint @ InlineCompletionMenuHint::Loaded { .. },
) => div().min_w(px(250.)).max_w(px(500.)).child( ) => div().min_w(px(250.)).max_w(px(500.)).child(
ListItem::new("inline-completion") ListItem::new("inline-completion")
.inset(true) .inset(true)
.toggle_state(item_ix == selected_item) .toggle_state(item_ix == selected_item)
.start_slot(Icon::new(IconName::ZedPredict)) // TODO: Ideally the contents would display on hover.
.when(translucent, |this| this.opacity(0.2))
.start_slot(
div()
.child(Icon::new(IconName::ZedPredict))
.when(translucent, |this| this.opacity(0.)),
)
.child( .child(
base_label.child( base_label.child(
StyledText::new(hint.label()) StyledText::new(hint.label())
.with_highlights(&style.text, None), .with_highlights(&editor_text_style, None),
), ),
) )
.on_click(cx.listener(move |editor, _event, cx| { .on_click(cx.listener(move |editor, _event, cx| {
@ -698,7 +782,20 @@ impl CompletionsMenu {
.with_width_from_item(widest_completion_ix) .with_width_from_item(widest_completion_ix)
.with_sizing_behavior(ListSizingBehavior::Infer); .with_sizing_behavior(ListSizingBehavior::Infer);
Popover::new().child(list).into_any_element() let elision = if translucent {
if self.scroll_handle.y_flipped() {
PopoverElision::TranslucentWithCroppedTop
} else {
PopoverElision::TranslucentWithCroppedBottom
}
} else {
PopoverElision::None
};
Popover::new()
.elision(elision)
.child(list)
.into_any_element()
} }
fn render_aside( fn render_aside(
@ -734,19 +831,6 @@ impl CompletionsMenu {
Documentation::Undocumented => return None, Documentation::Undocumented => return None,
} }
} }
CompletionEntry::InlineCompletionHint(InlineCompletionMenuHint::Loaded { text }) => {
match text {
InlineCompletionText::Edit { text, highlights } => div()
.mx_1()
.rounded_md()
.bg(cx.theme().colors().editor_background)
.child(
gpui::StyledText::new(text.clone())
.with_highlights(&style.text, highlights.clone()),
),
InlineCompletionText::Move(text) => div().child(text.clone()),
}
}
CompletionEntry::InlineCompletionHint(_) => return None, CompletionEntry::InlineCompletionHint(_) => return None,
}; };
@ -766,10 +850,7 @@ impl CompletionsMenu {
} }
pub async fn filter(&mut self, query: Option<&str>, executor: BackgroundExecutor) { pub async fn filter(&mut self, query: Option<&str>, executor: BackgroundExecutor) {
let inline_completion_was_selected = self.selected_item == 0 let inline_completion_was_selected = self.inline_completion_selected();
&& self.entries.borrow().first().map_or(false, |entry| {
matches!(entry, CompletionEntry::InlineCompletionHint(_))
});
let mut matches = if let Some(query) = query { let mut matches = if let Some(query) = query {
fuzzy::match_strings( fuzzy::match_strings(

View file

@ -7548,6 +7548,18 @@ impl Editor {
}); });
} }
pub fn context_menu_select_initial_lsp_completion(&mut self, cx: &mut ViewContext<Self>) {
if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() {
match context_menu {
CodeContextMenu::Completions(completions_menu) => {
completions_menu
.select_initial_lsp_completion(self.completion_provider.as_deref(), cx);
}
CodeContextMenu::CodeActions(_) => {}
}
}
}
pub fn context_menu_first(&mut self, _: &ContextMenuFirst, cx: &mut ViewContext<Self>) { pub fn context_menu_first(&mut self, _: &ContextMenuFirst, cx: &mut ViewContext<Self>) {
if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() {
context_menu.select_first(self.completion_provider.as_deref(), cx); context_menu.select_first(self.completion_provider.as_deref(), cx);

View file

@ -42,6 +42,7 @@ pub struct ListItem {
outlined: bool, outlined: bool,
overflow_x: bool, overflow_x: bool,
focused: Option<bool>, focused: Option<bool>,
opacity: f32,
} }
impl ListItem { impl ListItem {
@ -67,6 +68,7 @@ impl ListItem {
outlined: false, outlined: false,
overflow_x: false, overflow_x: false,
focused: None, focused: None,
opacity: 1.0,
} }
} }
@ -155,6 +157,11 @@ impl ListItem {
self.focused = Some(focused); self.focused = Some(focused);
self self
} }
pub fn opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity;
self
}
} }
impl Disableable for ListItem { impl Disableable for ListItem {
@ -183,6 +190,7 @@ impl RenderOnce for ListItem {
.id(self.id) .id(self.id)
.w_full() .w_full()
.relative() .relative()
.opacity(self.opacity)
// When an item is inset draw the indent spacing outside of the item // When an item is inset draw the indent spacing outside of the item
.when(self.inset, |this| { .when(self.inset, |this| {
this.ml(self.indent_level as f32 * self.indent_step_size) this.ml(self.indent_level as f32 * self.indent_step_size)

View file

@ -2,6 +2,7 @@
use crate::prelude::*; use crate::prelude::*;
use crate::v_flex; use crate::v_flex;
use crate::ElevationIndex;
use gpui::{ use gpui::{
div, AnyElement, Element, IntoElement, ParentElement, Pixels, RenderOnce, Styled, WindowContext, div, AnyElement, Element, IntoElement, ParentElement, Pixels, RenderOnce, Styled, WindowContext,
}; };
@ -41,19 +42,44 @@ pub const POPOVER_Y_PADDING: Pixels = px(8.);
pub struct Popover { pub struct Popover {
children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>,
aside: Option<AnyElement>, aside: Option<AnyElement>,
elision: PopoverElision,
}
pub enum PopoverElision {
None,
TranslucentWithCroppedTop,
TranslucentWithCroppedBottom,
} }
impl RenderOnce for Popover { impl RenderOnce for Popover {
fn render(self, cx: &mut WindowContext) -> impl IntoElement { fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let inner = v_flex()
.rounded_lg()
.border_1()
.py(POPOVER_Y_PADDING / 2.)
.children(self.children);
let inner = match self.elision {
PopoverElision::None => inner
.bg(cx.theme().colors().elevated_surface_background)
.border_color(cx.theme().colors().border_variant)
.shadow(ElevationIndex::ElevatedSurface.shadow()),
PopoverElision::TranslucentWithCroppedBottom => inner
.bg(cx.theme().colors().elevated_surface_background.opacity(0.5))
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.rounded_bl_none()
.rounded_br_none()
.border_b(px(0.)),
PopoverElision::TranslucentWithCroppedTop => inner
.bg(cx.theme().colors().elevated_surface_background.opacity(0.5))
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.rounded_tl_none()
.rounded_tr_none()
.border_t(px(0.)),
};
div() div()
.flex() .flex()
.gap_1() .gap_1()
.child( .child(inner)
v_flex()
.elevation_2(cx)
.py(POPOVER_Y_PADDING / 2.)
.children(self.children),
)
.when_some(self.aside, |this, aside| { .when_some(self.aside, |this, aside| {
this.child( this.child(
v_flex() v_flex()
@ -77,6 +103,7 @@ impl Popover {
Self { Self {
children: SmallVec::new(), children: SmallVec::new(),
aside: None, aside: None,
elision: PopoverElision::None,
} }
} }
@ -87,6 +114,14 @@ impl Popover {
self.aside = Some(aside.into_element().into_any()); self.aside = Some(aside.into_element().into_any());
self self
} }
pub fn elision(mut self, elision: PopoverElision) -> Self
where
Self: Sized,
{
self.elision = elision;
self
}
} }
impl ParentElement for Popover { impl ParentElement for Popover {