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 gpui::{
div, pulsating_between, px, uniform_list, Animation, AnimationExt, AnyElement,
BackgroundExecutor, Div, FontWeight, ListSizingBehavior, Model, ScrollStrategy, SharedString,
Size, StrikethroughStyle, StyledText, UniformListScrollHandle, ViewContext, WeakView,
BackgroundExecutor, Div, FontWeight, Hsla, ListSizingBehavior, Model, ScrollStrategy,
SharedString, Size, StrikethroughStyle, StyledText, TextStyleRefinement,
UniformListScrollHandle, ViewContext, WeakView,
};
use language::Buffer;
use language::{CodeLabel, Documentation};
@ -20,7 +21,7 @@ use std::{
rc::Rc,
};
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 workspace::Workspace;
@ -30,7 +31,7 @@ use crate::{
render_parsed_markdown, split_words, styled_runs_for_code_label, CodeActionProvider,
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_ASIDE_X_PADDING: Pixels = px(16.);
@ -320,6 +321,19 @@ impl CompletionsMenu {
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(
&mut self,
match_index: usize,
@ -353,13 +367,10 @@ impl CompletionsMenu {
pub fn show_inline_completion_hint(&mut self, hint: InlineCompletionMenuHint) {
let hint = CompletionEntry::InlineCompletionHint(hint);
let mut entries = self.entries.borrow_mut();
match entries.first() {
Some(CompletionEntry::InlineCompletionHint { .. }) => {
entries[0] = hint;
}
_ => {
entries.insert(0, hint);
if self.inline_completion_present() {
self.entries.borrow_mut()[0] = hint;
} else {
self.entries.borrow_mut().insert(0, hint);
// When `y_flipped`, need to scroll to bring it into view.
if self.selected_item == 0 {
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(
@ -467,7 +500,7 @@ impl CompletionsMenu {
fn render(
&self,
style: &EditorStyle,
max_height_in_lines: u32,
mut max_height_in_lines: u32,
y_flipped: bool,
cx: &mut ViewContext<Editor>,
) -> AnyElement {
@ -499,11 +532,25 @@ impl CompletionsMenu {
.map(|(ix, _)| ix);
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 completions = self.completions.clone();
let entries = self.entries.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(
cx.view().clone(),
"completions",
@ -513,16 +560,24 @@ impl CompletionsMenu {
let start_ix = range.start;
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]
.iter()
.enumerate()
.map(|(ix, mat)| {
.map(move |(ix, mat)| {
let item_ix = start_ix + ix;
let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
let base_label = h_flex()
.gap_1()
.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 {
CompletionEntry::Match(mat) => {
@ -543,8 +598,12 @@ impl CompletionsMenu {
FontWeight::BOLD.into(),
)
}),
styled_runs_for_code_label(&completion.label, &style.syntax)
.map(|(range, mut highlight)| {
styled_runs_for_code_label(
&completion.label,
&editor_syntax_theme,
)
.map(
|(range, mut highlight)| {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
@ -561,39 +620,55 @@ impl CompletionsMenu {
}
(range, highlight)
}),
},
),
);
let completion_label =
div().when(translucent, |this| this.opacity(0.)).child(
StyledText::new(completion.label.text.clone())
.with_highlights(&style.text, highlights);
.with_highlights(&editor_text_style, highlights),
);
let documentation_label =
if let Some(Documentation::SingleLine(text)) = documentation {
if text.trim().is_empty() {
None
} else {
Some(
div()
.when(translucent, |this| this.opacity(0.))
.child(
Label::new(text.clone())
.ml_4()
.size(LabelSize::Small)
.color(Color::Muted),
),
)
}
} else {
None
};
let color_swatch = completion
.color()
.map(|color| div().size_4().bg(color).rounded_sm());
let color_swatch = completion.color().map(|color| {
div()
.size_4()
.rounded_sm()
.when(!translucent, |this| this.bg(color))
});
div().min_w(px(220.)).max_w(px(540.)).child(
ListItem::new(mat.candidate_id)
.inset(true)
.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| {
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 {
item_ix: Some(item_ix),
},
@ -604,7 +679,7 @@ impl CompletionsMenu {
}))
.start_slot::<Div>(color_swatch)
.child(h_flex().overflow_hidden().child(completion_label))
.end_slot::<Label>(documentation_label),
.end_slot::<Div>(documentation_label),
)
}
CompletionEntry::InlineCompletionHint(
@ -617,7 +692,7 @@ impl CompletionsMenu {
.child(
base_label.child(
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)
.start_slot(Icon::new(IconName::ZedPredict))
.child(base_label.child({
let text_style = style.text.clone();
StyledText::new(hint.label())
.with_highlights(&text_style, None)
.with_highlights(&editor_text_style, None)
.with_animation(
"pulsating-label",
Animation::new(Duration::from_secs(1))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
{
let editor_text_style =
editor_text_style.clone();
move |text, delta| {
let mut text_style = text_style.clone();
let mut text_style =
(*editor_text_style).clone();
text_style.color =
text_style.color.opacity(delta);
text.with_highlights(&text_style, None)
}
},
)
})),
@ -656,7 +735,7 @@ impl CompletionsMenu {
.child(
base_label.child(
StyledText::new(hint.label())
.with_highlights(&style.text, None),
.with_highlights(&editor_text_style, None),
),
)
.on_click(cx.listener(move |editor, _event, cx| {
@ -664,18 +743,23 @@ impl CompletionsMenu {
editor.toggle_zed_predict_tos(cx);
})),
),
CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::Loaded { .. },
) => div().min_w(px(250.)).max_w(px(500.)).child(
ListItem::new("inline-completion")
.inset(true)
.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(
base_label.child(
StyledText::new(hint.label())
.with_highlights(&style.text, None),
.with_highlights(&editor_text_style, None),
),
)
.on_click(cx.listener(move |editor, _event, cx| {
@ -698,7 +782,20 @@ impl CompletionsMenu {
.with_width_from_item(widest_completion_ix)
.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(
@ -734,19 +831,6 @@ impl CompletionsMenu {
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,
};
@ -766,10 +850,7 @@ impl CompletionsMenu {
}
pub async fn filter(&mut self, query: Option<&str>, executor: BackgroundExecutor) {
let inline_completion_was_selected = self.selected_item == 0
&& self.entries.borrow().first().map_or(false, |entry| {
matches!(entry, CompletionEntry::InlineCompletionHint(_))
});
let inline_completion_was_selected = self.inline_completion_selected();
let mut matches = if let Some(query) = query {
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>) {
if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() {
context_menu.select_first(self.completion_provider.as_deref(), cx);

View file

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

View file

@ -2,6 +2,7 @@
use crate::prelude::*;
use crate::v_flex;
use crate::ElevationIndex;
use gpui::{
div, AnyElement, Element, IntoElement, ParentElement, Pixels, RenderOnce, Styled, WindowContext,
};
@ -41,19 +42,44 @@ pub const POPOVER_Y_PADDING: Pixels = px(8.);
pub struct Popover {
children: SmallVec<[AnyElement; 2]>,
aside: Option<AnyElement>,
elision: PopoverElision,
}
pub enum PopoverElision {
None,
TranslucentWithCroppedTop,
TranslucentWithCroppedBottom,
}
impl RenderOnce for Popover {
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()
.flex()
.gap_1()
.child(
v_flex()
.elevation_2(cx)
.py(POPOVER_Y_PADDING / 2.)
.children(self.children),
)
.child(inner)
.when_some(self.aside, |this, aside| {
this.child(
v_flex()
@ -77,6 +103,7 @@ impl Popover {
Self {
children: SmallVec::new(),
aside: None,
elision: PopoverElision::None,
}
}
@ -87,6 +114,14 @@ impl Popover {
self.aside = Some(aside.into_element().into_any());
self
}
pub fn elision(mut self, elision: PopoverElision) -> Self
where
Self: Sized,
{
self.elision = elision;
self
}
}
impl ParentElement for Popover {