zeta: Improve UX for simultaneous LSP and prediction completions (#24024)

Release Notes:

- N/A

---------

Co-authored-by: Michael Sloan <michael@zed.dev>
Co-authored-by: Danilo <danilo@zed.dev>
Co-authored-by: Richard <richard@zed.dev>
This commit is contained in:
Agus Zubiaga 2025-02-03 21:47:11 -03:00 committed by GitHub
parent b6e680ea3d
commit 4c29e1ff07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1196 additions and 762 deletions

View file

@ -509,6 +509,13 @@
"tab": "editor::AcceptInlineCompletion"
}
},
{
"context": "Editor && inline_completion && showing_completions",
"bindings": {
// Currently, changing this binding breaks the preview behavior
"alt-enter": "editor::AcceptInlineCompletion"
}
},
{
"context": "Editor && showing_code_actions",
"bindings": {

View file

@ -586,6 +586,13 @@
"tab": "editor::AcceptInlineCompletion"
}
},
{
"context": "Editor && inline_completion && showing_completions",
"bindings": {
// Currently, changing this binding breaks the preview behavior
"alt-tab": "editor::AcceptInlineCompletion"
}
},
{
"context": "Editor && showing_code_actions",
"use_key_equivalents": true,

View file

@ -341,7 +341,6 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
assert!(editor.context_menu_visible());
assert!(!editor.context_menu_contains_inline_completion());
assert!(!editor.has_active_inline_completion());
// Since we have both, the copilot suggestion is not shown inline
assert_eq!(editor.text(cx), "one.\ntwo\nthree\n");
@ -399,7 +398,6 @@ mod tests {
executor.run_until_parked();
cx.update_editor(|editor, _, cx| {
assert!(!editor.context_menu_visible());
assert!(!editor.context_menu_contains_inline_completion());
assert!(editor.has_active_inline_completion());
assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n");
@ -419,7 +417,6 @@ mod tests {
cx.update_editor(|editor, window, cx| {
assert!(!editor.context_menu_visible());
assert!(editor.has_active_inline_completion());
assert!(!editor.context_menu_contains_inline_completion());
assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n");
@ -934,7 +931,6 @@ mod tests {
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
assert!(editor.context_menu_visible());
assert!(!editor.context_menu_contains_inline_completion());
assert!(!editor.has_active_inline_completion(),);
assert_eq!(editor.text(cx), "one\ntwo.\nthree\n");
});

View file

@ -1,8 +1,8 @@
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
div, pulsating_between, px, uniform_list, Animation, AnimationExt, AnyElement,
BackgroundExecutor, Div, Entity, FontWeight, ListSizingBehavior, ScrollStrategy, SharedString,
Size, StrikethroughStyle, StyledText, UniformListScrollHandle, WeakEntity,
div, px, uniform_list, AnyElement, BackgroundExecutor, Div, Entity, FontWeight,
ListSizingBehavior, ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText,
UniformListScrollHandle, WeakEntity,
};
use language::Buffer;
use language::{CodeLabel, CompletionDocumentation};
@ -10,8 +10,7 @@ use lsp::LanguageServerId;
use multi_buffer::{Anchor, ExcerptId};
use ordered_float::OrderedFloat;
use project::{CodeAction, Completion, TaskSourceKind};
use settings::Settings;
use std::time::Duration;
use std::{
cell::RefCell,
cmp::{min, Reverse},
@ -26,11 +25,9 @@ use workspace::Workspace;
use crate::{
actions::{ConfirmCodeAction, ConfirmCompletion},
display_map::DisplayPoint,
render_parsed_markdown, split_words, styled_runs_for_code_label, CodeActionProvider,
CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, ResolvedTasks,
};
use crate::{AcceptInlineCompletion, InlineCompletionMenuHint, InlineCompletionText};
pub const MENU_GAP: Pixels = px(4.);
pub const MENU_ASIDE_X_PADDING: Pixels = px(16.);
@ -114,10 +111,10 @@ impl CodeContextMenu {
}
}
pub fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
pub fn origin(&self) -> ContextMenuOrigin {
match self {
CodeContextMenu::Completions(menu) => menu.origin(cursor_position),
CodeContextMenu::CodeActions(menu) => menu.origin(cursor_position),
CodeContextMenu::Completions(menu) => menu.origin(),
CodeContextMenu::CodeActions(menu) => menu.origin(),
}
}
@ -154,7 +151,7 @@ impl CodeContextMenu {
}
pub enum ContextMenuOrigin {
EditorPoint(DisplayPoint),
Cursor,
GutterIndicator(DisplayRow),
}
@ -166,18 +163,13 @@ pub struct CompletionsMenu {
pub buffer: Entity<Buffer>,
pub completions: Rc<RefCell<Box<[Completion]>>>,
match_candidates: Rc<[StringMatchCandidate]>,
pub entries: Rc<RefCell<Vec<CompletionEntry>>>,
pub entries: Rc<RefCell<Vec<StringMatch>>>,
pub selected_item: usize,
scroll_handle: UniformListScrollHandle,
resolve_completions: bool,
show_completion_documentation: bool,
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
}
#[derive(Clone, Debug)]
pub(crate) enum CompletionEntry {
Match(StringMatch),
InlineCompletionHint(InlineCompletionMenuHint),
pub previewing_inline_completion: bool,
}
impl CompletionsMenu {
@ -208,6 +200,7 @@ impl CompletionsMenu {
scroll_handle: UniformListScrollHandle::new(),
resolve_completions: true,
last_rendered_range: RefCell::new(None).into(),
previewing_inline_completion: false,
}
}
@ -244,13 +237,11 @@ impl CompletionsMenu {
let entries = choices
.iter()
.enumerate()
.map(|(id, completion)| {
CompletionEntry::Match(StringMatch {
candidate_id: id,
score: 1.,
positions: vec![],
string: completion.clone(),
})
.map(|(id, completion)| StringMatch {
candidate_id: id,
score: 1.,
positions: vec![],
string: completion.clone(),
})
.collect::<Vec<_>>();
Self {
@ -266,6 +257,7 @@ impl CompletionsMenu {
resolve_completions: false,
show_completion_documentation: false,
last_rendered_range: RefCell::new(None).into(),
previewing_inline_completion: false,
}
}
@ -340,24 +332,6 @@ 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);
// When `y_flipped`, need to scroll to bring it into view.
if self.selected_item == 0 {
self.scroll_handle
.scroll_to_item(self.selected_item, ScrollStrategy::Top);
}
}
}
}
pub fn resolve_visible_completions(
&mut self,
provider: Option<&dyn CompletionProvider>,
@ -406,17 +380,15 @@ impl CompletionsMenu {
// This filtering doesn't happen if the completions are currently being updated.
let completions = self.completions.borrow();
let candidate_ids = entry_indices
.flat_map(|i| Self::entry_candidate_id(&entries[i]))
.map(|i| entries[i].candidate_id)
.filter(|i| completions[*i].documentation.is_none());
// Current selection is always resolved even if it already has documentation, to handle
// out-of-spec language servers that return more results later.
let candidate_ids = match Self::entry_candidate_id(&entries[self.selected_item]) {
None => candidate_ids.collect::<Vec<usize>>(),
Some(selected_candidate_id) => iter::once(selected_candidate_id)
.chain(candidate_ids.filter(|id| *id != selected_candidate_id))
.collect::<Vec<usize>>(),
};
let selected_candidate_id = entries[self.selected_item].candidate_id;
let candidate_ids = iter::once(selected_candidate_id)
.chain(candidate_ids.filter(|id| *id != selected_candidate_id))
.collect::<Vec<usize>>();
drop(entries);
if candidate_ids.is_empty() {
@ -438,19 +410,16 @@ impl CompletionsMenu {
.detach();
}
fn entry_candidate_id(entry: &CompletionEntry) -> Option<usize> {
match entry {
CompletionEntry::Match(entry) => Some(entry.candidate_id),
CompletionEntry::InlineCompletionHint { .. } => None,
}
pub fn is_empty(&self) -> bool {
self.entries.borrow().is_empty()
}
pub fn visible(&self) -> bool {
!self.entries.borrow().is_empty()
!self.is_empty() && !self.previewing_inline_completion
}
fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
ContextMenuOrigin::EditorPoint(cursor_position)
fn origin(&self) -> ContextMenuOrigin {
ContextMenuOrigin::Cursor
}
fn render(
@ -468,23 +437,18 @@ impl CompletionsMenu {
.borrow()
.iter()
.enumerate()
.max_by_key(|(_, mat)| match mat {
CompletionEntry::Match(mat) => {
let completion = &completions[mat.candidate_id];
let documentation = &completion.documentation;
.max_by_key(|(_, mat)| {
let completion = &completions[mat.candidate_id];
let documentation = &completion.documentation;
let mut len = completion.label.text.chars().count();
if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
if show_completion_documentation {
len += text.chars().count();
}
let mut len = completion.label.text.chars().count();
if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
if show_completion_documentation {
len += text.chars().count();
}
}
len
}
CompletionEntry::InlineCompletionHint(hint) => {
"Zed AI / ".chars().count() + hint.label().chars().count()
}
len
})
.map(|(ix, _)| ix);
drop(completions);
@ -508,179 +472,83 @@ impl CompletionsMenu {
.enumerate()
.map(|(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));
let completion = &completions_guard[mat.candidate_id];
let documentation = if show_completion_documentation {
&completion.documentation
} else {
&None
};
match mat {
CompletionEntry::Match(mat) => {
let candidate_id = mat.candidate_id;
let completion = &completions_guard[candidate_id];
let filter_start = completion.label.filter_range.start;
let highlights = gpui::combine_highlights(
mat.ranges().map(|range| {
(
filter_start + range.start..filter_start + range.end,
FontWeight::BOLD.into(),
)
}),
styled_runs_for_code_label(&completion.label, &style.syntax).map(
|(range, mut highlight)| {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
if completion.lsp_completion.deprecated.unwrap_or(false) {
highlight.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
highlight.color = Some(cx.theme().colors().text_muted);
}
let documentation = if show_completion_documentation {
&completion.documentation
} else {
&None
};
(range, highlight)
},
),
);
let filter_start = completion.label.filter_range.start;
let highlights = gpui::combine_highlights(
mat.ranges().map(|range| {
(
filter_start + range.start..filter_start + range.end,
FontWeight::BOLD.into(),
)
}),
styled_runs_for_code_label(&completion.label, &style.syntax)
.map(|(range, mut highlight)| {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
if completion.lsp_completion.deprecated.unwrap_or(false)
{
highlight.strikethrough =
Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
highlight.color =
Some(cx.theme().colors().text_muted);
}
(range, highlight)
}),
);
let completion_label =
StyledText::new(completion.label.text.clone())
.with_highlights(&style.text, highlights);
let documentation_label =
if let Some(CompletionDocumentation::SingleLine(text)) =
documentation
{
if text.trim().is_empty() {
None
} else {
Some(
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());
div().min_w(px(220.)).max_w(px(540.)).child(
ListItem::new(mat.candidate_id)
.inset(true)
.toggle_state(item_ix == selected_item)
.on_click(cx.listener(move |editor, _event, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_completion(
&ConfirmCompletion {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
}))
.start_slot::<Div>(color_swatch)
.child(h_flex().overflow_hidden().child(completion_label))
.end_slot::<Label>(documentation_label),
let completion_label = StyledText::new(completion.label.text.clone())
.with_highlights(&style.text, highlights);
let documentation_label = if let Some(
CompletionDocumentation::SingleLine(text),
) = documentation
{
if text.trim().is_empty() {
None
} else {
Some(
Label::new(text.clone())
.ml_4()
.size(LabelSize::Small)
.color(Color::Muted),
)
}
CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::None,
) => 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))
.child(
base_label.child(
StyledText::new(hint.label())
.with_highlights(&style.text, None),
),
),
),
CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::Loading,
) => 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))
.child(base_label.child({
let text_style = style.text.clone();
StyledText::new(hint.label())
.with_highlights(&text_style, None)
.with_animation(
"pulsating-label",
Animation::new(Duration::from_secs(1))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
move |text, delta| {
let mut text_style = text_style.clone();
text_style.color =
text_style.color.opacity(delta);
text.with_highlights(&text_style, None)
},
)
})),
),
CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::PendingTermsAcceptance,
) => 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))
.child(
base_label.child(
StyledText::new(hint.label())
.with_highlights(&style.text, None),
),
)
.on_click(cx.listener(move |editor, _event, window, cx| {
cx.stop_propagation();
editor.toggle_zed_predict_onboarding(window, cx);
})),
),
} else {
None
};
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))
.child(
base_label.child(
StyledText::new(hint.label())
.with_highlights(&style.text, None),
),
)
.on_click(cx.listener(move |editor, _event, window, cx| {
cx.stop_propagation();
editor.accept_inline_completion(
&AcceptInlineCompletion {},
window,
cx,
);
})),
),
}
let color_swatch = completion
.color()
.map(|color| div().size_4().bg(color).rounded_sm());
div().min_w(px(280.)).max_w(px(540.)).child(
ListItem::new(mat.candidate_id)
.inset(true)
.toggle_state(item_ix == selected_item)
.on_click(cx.listener(move |editor, _event, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_completion(
&ConfirmCompletion {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
}))
.start_slot::<Div>(color_swatch)
.child(h_flex().overflow_hidden().child(completion_label))
.end_slot::<Label>(documentation_label),
)
})
.collect()
},
@ -706,45 +574,25 @@ impl CompletionsMenu {
return None;
}
let multiline_docs = match &self.entries.borrow()[self.selected_item] {
CompletionEntry::Match(mat) => {
match self.completions.borrow_mut()[mat.candidate_id]
.documentation
.as_ref()?
{
CompletionDocumentation::MultiLinePlainText(text) => {
div().child(SharedString::from(text.clone()))
}
CompletionDocumentation::MultiLineMarkdown(parsed)
if !parsed.text.is_empty() =>
{
div().child(render_parsed_markdown(
"completions_markdown",
parsed,
&style,
workspace,
cx,
))
}
CompletionDocumentation::MultiLineMarkdown(_) => return None,
CompletionDocumentation::SingleLine(_) => return None,
CompletionDocumentation::Undocumented => return None,
}
let mat = &self.entries.borrow()[self.selected_item];
let multiline_docs = match self.completions.borrow_mut()[mat.candidate_id]
.documentation
.as_ref()?
{
CompletionDocumentation::MultiLinePlainText(text) => {
div().child(SharedString::from(text.clone()))
}
CompletionEntry::InlineCompletionHint(InlineCompletionMenuHint::Loaded { text }) => {
match text {
InlineCompletionText::Edit(highlighted_edits) => div()
.mx_1()
.rounded_md()
.bg(cx.theme().colors().editor_background)
.child(
gpui::StyledText::new(highlighted_edits.text.clone())
.with_highlights(&style.text, highlighted_edits.highlights.clone()),
),
InlineCompletionText::Move(text) => div().child(text.clone()),
}
}
CompletionEntry::InlineCompletionHint(_) => return None,
CompletionDocumentation::MultiLineMarkdown(parsed) if !parsed.text.is_empty() => div()
.child(render_parsed_markdown(
"completions_markdown",
parsed,
&style,
workspace,
cx,
)),
CompletionDocumentation::MultiLineMarkdown(_) => return None,
CompletionDocumentation::SingleLine(_) => return None,
CompletionDocumentation::Undocumented => return None,
};
Some(
@ -763,11 +611,6 @@ 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 mut matches = if let Some(query) = query {
fuzzy::match_strings(
&self.match_candidates,
@ -861,25 +704,15 @@ impl CompletionsMenu {
}
drop(completions);
let mut entries = self.entries.borrow_mut();
let new_selection = if let Some(CompletionEntry::InlineCompletionHint(_)) = entries.first()
{
entries.truncate(1);
if inline_completion_was_selected || matches.is_empty() {
0
} else {
1
}
} else {
entries.truncate(0);
0
};
entries.extend(matches.into_iter().map(CompletionEntry::Match));
self.selected_item = new_selection;
// Scroll to 0 even if the LSP completion is the only one selected. This keeps the display
// consistent when y_flipped.
*self.entries.borrow_mut() = matches;
self.selected_item = 0;
// This keeps the display consistent when y_flipped.
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
}
pub fn set_previewing_inline_completion(&mut self, value: bool) {
self.previewing_inline_completion = value;
}
}
#[derive(Clone)]
@ -1077,11 +910,11 @@ impl CodeActionsMenu {
!self.actions.is_empty()
}
fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
fn origin(&self) -> ContextMenuOrigin {
if let Some(row) = self.deployed_from_indicator {
ContextMenuOrigin::GutterIndicator(row)
} else {
ContextMenuOrigin::EditorPoint(cursor_position)
ContextMenuOrigin::Cursor
}
}

View file

@ -508,7 +508,7 @@ impl DisplayMap {
pub(crate) fn splice_inlays(
&mut self,
to_remove: Vec<InlayId>,
to_remove: &[InlayId],
to_insert: Vec<Inlay>,
cx: &mut Context<Self>,
) {

View file

@ -545,7 +545,7 @@ impl InlayMap {
pub fn splice(
&mut self,
to_remove: Vec<InlayId>,
to_remove: &[InlayId],
to_insert: Vec<Inlay>,
) -> (InlaySnapshot, Vec<InlayEdit>) {
let snapshot = &mut self.snapshot;
@ -653,7 +653,7 @@ impl InlayMap {
}
log::info!("removing inlays: {:?}", to_remove);
let (snapshot, edits) = self.splice(to_remove, to_insert);
let (snapshot, edits) = self.splice(&to_remove, to_insert);
(snapshot, edits)
}
}
@ -1171,7 +1171,7 @@ mod tests {
let mut next_inlay_id = 0;
let (inlay_snapshot, _) = inlay_map.splice(
Vec::new(),
&[],
vec![Inlay {
id: InlayId::Hint(post_inc(&mut next_inlay_id)),
position: buffer.read(cx).snapshot(cx).anchor_after(3),
@ -1247,7 +1247,7 @@ mod tests {
assert_eq!(inlay_snapshot.text(), "abxyDzefghi");
let (inlay_snapshot, _) = inlay_map.splice(
Vec::new(),
&[],
vec![
Inlay {
id: InlayId::Hint(post_inc(&mut next_inlay_id)),
@ -1444,7 +1444,11 @@ mod tests {
// The inlays can be manually removed.
let (inlay_snapshot, _) = inlay_map.splice(
inlay_map.inlays.iter().map(|inlay| inlay.id).collect(),
&inlay_map
.inlays
.iter()
.map(|inlay| inlay.id)
.collect::<Vec<InlayId>>(),
Vec::new(),
);
assert_eq!(inlay_snapshot.text(), "abxJKLyDzefghi");
@ -1458,7 +1462,7 @@ mod tests {
let mut next_inlay_id = 0;
let (inlay_snapshot, _) = inlay_map.splice(
Vec::new(),
&[],
vec![
Inlay {
id: InlayId::Hint(post_inc(&mut next_inlay_id)),

View file

@ -73,17 +73,18 @@ use zed_predict_onboarding::ZedPredictModal;
use code_context_menus::{
AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
CompletionEntry, CompletionsMenu, ContextMenuOrigin,
CompletionsMenu, ContextMenuOrigin,
};
use git::blame::GitBlame;
use gpui::{
div, impl_actions, point, prelude::*, px, relative, size, Action, AnyElement, App,
AsyncWindowContext, AvailableSpace, Bounds, ClipboardEntry, ClipboardItem, Context,
DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent,
Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, InteractiveText, KeyContext,
MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size,
Styled, StyledText, Subscription, Task, TextStyle, TextStyleRefinement, UTF16Selection,
UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window,
div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, Action, Animation,
AnimationExt, AnyElement, App, AsyncWindowContext, AvailableSpace, Bounds, ClipboardEntry,
ClipboardItem, Context, DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter,
FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla,
InteractiveText, KeyContext, Modifiers, MouseButton, MouseDownEvent, PaintQuad, ParentElement,
Pixels, Render, SharedString, Size, Styled, StyledText, Subscription, Task, TextStyle,
TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity,
WeakFocusHandle, Window,
};
use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_popover::{hide_hover, HoverState};
@ -107,7 +108,7 @@ pub use proposed_changes_editor::{
ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
};
use similar::{ChangeTag, TextDiff};
use std::iter::Peekable;
use std::iter::{self, Peekable};
use task::{ResolvedTask, TaskTemplate, TaskVariables};
use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight};
@ -163,7 +164,7 @@ use ui::{
h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize,
Tooltip,
};
use util::{defer, maybe, post_inc, RangeExt, ResultExt, TryFutureExt};
use util::{defer, maybe, post_inc, RangeExt, ResultExt, TakeUntilExt, TryFutureExt};
use workspace::item::{ItemHandle, PreviewTabsSettings};
use workspace::notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt};
use workspace::{
@ -465,32 +466,6 @@ pub fn make_suggestion_styles(cx: &mut App) -> InlineCompletionStyles {
type CompletionId = usize;
#[derive(Debug, Clone)]
enum InlineCompletionMenuHint {
Loading,
Loaded { text: InlineCompletionText },
PendingTermsAcceptance,
None,
}
impl InlineCompletionMenuHint {
pub fn label(&self) -> &'static str {
match self {
InlineCompletionMenuHint::Loading | InlineCompletionMenuHint::Loaded { .. } => {
"Edit Prediction"
}
InlineCompletionMenuHint::PendingTermsAcceptance => "Accept Terms of Service",
InlineCompletionMenuHint::None => "No Prediction",
}
}
}
#[derive(Clone, Debug)]
enum InlineCompletionText {
Move(SharedString),
Edit(HighlightedText),
}
pub(crate) enum EditDisplayMode {
TabAccept,
DiffPopover,
@ -504,7 +479,11 @@ enum InlineCompletion {
display_mode: EditDisplayMode,
snapshot: BufferSnapshot,
},
Move(Anchor),
Move {
target: Anchor,
range_around_target: Range<text::Anchor>,
snapshot: BufferSnapshot,
},
}
struct InlineCompletionState {
@ -513,6 +492,15 @@ struct InlineCompletionState {
invalidation_range: Range<Anchor>,
}
impl InlineCompletionState {
pub fn is_move(&self) -> bool {
match &self.completion {
InlineCompletion::Move { .. } => true,
_ => false,
}
}
}
enum InlineCompletionHighlight {}
pub enum MenuInlineCompletionsPolicy {
@ -687,6 +675,8 @@ pub struct Editor {
inline_completion_provider: Option<RegisteredInlineCompletionProvider>,
code_action_providers: Vec<Rc<dyn CodeActionProvider>>,
active_inline_completion: Option<InlineCompletionState>,
/// Used to prevent flickering as the user types while the menu is open
stale_inline_completion_in_menu: Option<InlineCompletionState>,
// enable_inline_completions is a switch that Vim can use to disable
// inline completions based on its mode.
enable_inline_completions: bool,
@ -1381,6 +1371,7 @@ impl Editor {
hovered_link_state: Default::default(),
inline_completion_provider: None,
active_inline_completion: None,
stale_inline_completion_in_menu: None,
inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
gutter_hovered: false,
@ -1496,7 +1487,7 @@ impl Editor {
match self.context_menu.borrow().as_ref() {
Some(CodeContextMenu::Completions(_)) => {
key_context.add("menu");
key_context.add("showing_completions")
key_context.add("showing_completions");
}
Some(CodeContextMenu::CodeActions(_)) => {
key_context.add("menu");
@ -2611,9 +2602,6 @@ impl Editor {
}
if self.hide_context_menu(window, cx).is_some() {
if self.show_inline_completions_in_menu(cx) && self.has_active_inline_completion() {
self.update_visible_inline_completion(window, cx);
}
return true;
}
@ -3601,10 +3589,11 @@ impl Editor {
} else {
self.inlay_hint_cache.clear();
self.splice_inlays(
self.visible_inlay_hints(cx)
&self
.visible_inlay_hints(cx)
.iter()
.map(|inlay| inlay.id)
.collect(),
.collect::<Vec<InlayId>>(),
Vec::new(),
cx,
);
@ -3622,7 +3611,7 @@ impl Editor {
to_remove,
to_insert,
})) => {
self.splice_inlays(to_remove, to_insert, cx);
self.splice_inlays(&to_remove, to_insert, cx);
return;
}
ControlFlow::Break(None) => return,
@ -3635,7 +3624,7 @@ impl Editor {
to_insert,
}) = self.inlay_hint_cache.remove_excerpts(excerpts_removed)
{
self.splice_inlays(to_remove, to_insert, cx);
self.splice_inlays(&to_remove, to_insert, cx);
}
return;
}
@ -3658,7 +3647,7 @@ impl Editor {
ignore_debounce,
cx,
) {
self.splice_inlays(to_remove, to_insert, cx);
self.splice_inlays(&to_remove, to_insert, cx);
}
}
@ -3738,7 +3727,7 @@ impl Editor {
pub fn splice_inlays(
&self,
to_remove: Vec<InlayId>,
to_remove: &[InlayId],
to_insert: Vec<Inlay>,
cx: &mut Context<Self>,
) {
@ -3905,17 +3894,15 @@ impl Editor {
let mut menu = menu.unwrap();
menu.resolve_visible_completions(editor.completion_provider.as_deref(), cx);
*editor.context_menu.borrow_mut() =
Some(CodeContextMenu::Completions(menu));
if editor.show_inline_completions_in_menu(cx) {
if let Some(hint) = editor.inline_completion_menu_hint(window, cx) {
menu.show_inline_completion_hint(hint);
}
editor.update_visible_inline_completion(window, cx);
} else {
editor.discard_inline_completion(false, cx);
}
*editor.context_menu.borrow_mut() =
Some(CodeContextMenu::Completions(menu));
cx.notify();
} else if editor.completion_tasks.len() <= 1 {
// If there are no more completion tasks and the last menu was
@ -3982,34 +3969,6 @@ impl Editor {
) -> Option<Task<std::result::Result<(), anyhow::Error>>> {
use language::ToOffset as _;
{
let context_menu = self.context_menu.borrow();
if let CodeContextMenu::Completions(menu) = context_menu.as_ref()? {
let entries = menu.entries.borrow();
let entry = entries.get(item_ix.unwrap_or(menu.selected_item));
match entry {
Some(CompletionEntry::InlineCompletionHint(
InlineCompletionMenuHint::Loading,
)) => return Some(Task::ready(Ok(()))),
Some(CompletionEntry::InlineCompletionHint(InlineCompletionMenuHint::None)) => {
drop(entries);
drop(context_menu);
self.context_menu_next(&Default::default(), window, cx);
return Some(Task::ready(Ok(())));
}
Some(CompletionEntry::InlineCompletionHint(
InlineCompletionMenuHint::PendingTermsAcceptance,
)) => {
drop(entries);
drop(context_menu);
self.toggle_zed_predict_onboarding(window, cx);
return Some(Task::ready(Ok(())));
}
_ => {}
}
}
}
let completions_menu =
if let CodeContextMenu::Completions(menu) = self.hide_context_menu(window, cx)? {
menu
@ -4019,19 +3978,9 @@ impl Editor {
let entries = completions_menu.entries.borrow();
let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?;
let mat = match mat {
CompletionEntry::InlineCompletionHint(_) => {
self.accept_inline_completion(&AcceptInlineCompletion, window, cx);
cx.stop_propagation();
return Some(Task::ready(Ok(())));
}
CompletionEntry::Match(mat) => {
if self.show_inline_completions_in_menu(cx) {
self.discard_inline_completion(true, cx);
}
mat
}
};
if self.show_inline_completions_in_menu(cx) {
self.discard_inline_completion(true, cx);
}
let candidate_id = mat.candidate_id;
drop(entries);
@ -4863,10 +4812,10 @@ impl Editor {
self.report_inline_completion_event(true, cx);
match &active_inline_completion.completion {
InlineCompletion::Move(position) => {
let position = *position;
InlineCompletion::Move { target, .. } => {
let target = *target;
self.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
selections.select_anchor_ranges([position..position]);
selections.select_anchor_ranges([target..target]);
});
}
InlineCompletion::Edit { edits, .. } => {
@ -4911,10 +4860,10 @@ impl Editor {
self.report_inline_completion_event(true, cx);
match &active_inline_completion.completion {
InlineCompletion::Move(position) => {
let position = *position;
InlineCompletion::Move { target, .. } => {
let target = *target;
self.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
selections.select_anchor_ranges([position..position]);
selections.select_anchor_ranges([target..target]);
});
}
InlineCompletion::Edit { edits, .. } => {
@ -4973,7 +4922,7 @@ impl Editor {
provider.discard(cx);
}
self.take_active_inline_completion(cx).is_some()
self.take_active_inline_completion(cx)
}
fn report_inline_completion_event(&self, accepted: bool, cx: &App) {
@ -5010,19 +4959,58 @@ impl Editor {
self.active_inline_completion.is_some()
}
fn take_active_inline_completion(
&mut self,
cx: &mut Context<Self>,
) -> Option<InlineCompletion> {
let active_inline_completion = self.active_inline_completion.take()?;
self.splice_inlays(active_inline_completion.inlay_ids, Default::default(), cx);
fn take_active_inline_completion(&mut self, cx: &mut Context<Self>) -> bool {
let Some(active_inline_completion) = self.active_inline_completion.take() else {
return false;
};
self.splice_inlays(&active_inline_completion.inlay_ids, Default::default(), cx);
self.clear_highlights::<InlineCompletionHighlight>(cx);
Some(active_inline_completion.completion)
self.stale_inline_completion_in_menu = Some(active_inline_completion);
true
}
fn update_inline_completion_preview(
&mut self,
modifiers: &Modifiers,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Moves jump directly with a preview step
if self
.active_inline_completion
.as_ref()
.map_or(true, |c| c.is_move())
{
cx.notify();
return;
}
if !self.show_inline_completions_in_menu(cx) {
return;
}
let mut menu_borrow = self.context_menu.borrow_mut();
let Some(CodeContextMenu::Completions(completions_menu)) = menu_borrow.as_mut() else {
return;
};
if completions_menu.is_empty()
|| completions_menu.previewing_inline_completion == modifiers.alt
{
return;
}
completions_menu.set_previewing_inline_completion(modifiers.alt);
drop(menu_borrow);
self.update_visible_inline_completion(window, cx);
}
fn update_visible_inline_completion(
&mut self,
window: &mut Window,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let selection = self.selections.newest_anchor();
@ -5031,7 +5019,8 @@ impl Editor {
let offset_selection = selection.map(|endpoint| endpoint.to_offset(&multibuffer));
let excerpt_id = cursor.excerpt_id;
let completions_menu_has_precedence = !self.show_inline_completions_in_menu(cx)
let show_in_menu = self.show_inline_completions_in_menu(cx);
let completions_menu_has_precedence = !show_in_menu
&& (self.context_menu.borrow().is_some()
|| (!self.completion_tasks.is_empty() && !self.has_active_inline_completion()));
if completions_menu_has_precedence
@ -5080,50 +5069,73 @@ impl Editor {
let cursor_row = cursor.to_point(&multibuffer).row;
let snapshot = multibuffer.buffer_for_excerpt(excerpt_id).cloned()?;
let mut inlay_ids = Vec::new();
let invalidation_row_range;
let completion = if cursor_row < edit_start_row {
invalidation_row_range = cursor_row..edit_end_row;
InlineCompletion::Move(first_edit_start)
let move_invalidation_row_range = if cursor_row < edit_start_row {
Some(cursor_row..edit_end_row)
} else if cursor_row > edit_end_row {
invalidation_row_range = edit_start_row..cursor_row;
InlineCompletion::Move(first_edit_start)
Some(edit_start_row..cursor_row)
} else {
if edits
.iter()
.all(|(range, _)| range.to_offset(&multibuffer).is_empty())
{
let mut inlays = Vec::new();
for (range, new_text) in &edits {
let inlay = Inlay::inline_completion(
post_inc(&mut self.next_inlay_id),
range.start,
new_text.as_str(),
);
inlay_ids.push(inlay.id);
inlays.push(inlay);
}
None
};
let completion = if let Some(move_invalidation_row_range) = move_invalidation_row_range {
invalidation_row_range = move_invalidation_row_range;
let target = first_edit_start;
let target_point = text::ToPoint::to_point(&target.text_anchor, &snapshot);
// TODO: Base this off of TreeSitter or word boundaries?
let target_excerpt_begin = snapshot.anchor_before(snapshot.clip_point(
Point::new(target_point.row, target_point.column.saturating_sub(10)),
Bias::Left,
));
let target_excerpt_end = snapshot.anchor_after(snapshot.clip_point(
Point::new(target_point.row, target_point.column + 10),
Bias::Right,
));
// TODO: Extend this to be before the jump target, and draw a cursor at the jump target
// (using Editor::current_user_player_color).
let range_around_target = target_excerpt_begin..target_excerpt_end;
InlineCompletion::Move {
target,
range_around_target,
snapshot,
}
} else {
if !show_in_menu || !self.has_active_completions_menu() {
if edits
.iter()
.all(|(range, _)| range.to_offset(&multibuffer).is_empty())
{
let mut inlays = Vec::new();
for (range, new_text) in &edits {
let inlay = Inlay::inline_completion(
post_inc(&mut self.next_inlay_id),
range.start,
new_text.as_str(),
);
inlay_ids.push(inlay.id);
inlays.push(inlay);
}
self.splice_inlays(vec![], inlays, cx);
} else {
let background_color = cx.theme().status().deleted_background;
self.highlight_text::<InlineCompletionHighlight>(
edits.iter().map(|(range, _)| range.clone()).collect(),
HighlightStyle {
background_color: Some(background_color),
..Default::default()
},
cx,
);
self.splice_inlays(&[], inlays, cx);
} else {
let background_color = cx.theme().status().deleted_background;
self.highlight_text::<InlineCompletionHighlight>(
edits.iter().map(|(range, _)| range.clone()).collect(),
HighlightStyle {
background_color: Some(background_color),
..Default::default()
},
cx,
);
}
}
invalidation_row_range = edit_start_row..edit_end_row;
let display_mode = if all_edits_insertions_or_deletions(&edits, &multibuffer) {
if provider.show_tab_accept_marker()
&& first_edit_start_point.row == last_edit_end_point.row
&& !edits.iter().any(|(_, edit)| edit.contains('\n'))
{
if provider.show_tab_accept_marker() {
EditDisplayMode::TabAccept
} else {
EditDisplayMode::Inline
@ -5132,8 +5144,6 @@ impl Editor {
EditDisplayMode::DiffPopover
};
let snapshot = multibuffer.buffer_for_excerpt(excerpt_id).cloned()?;
InlineCompletion::Edit {
edits,
edit_preview: inline_completion.edit_preview,
@ -5149,69 +5159,18 @@ impl Editor {
multibuffer.line_len(MultiBufferRow(invalidation_row_range.end)),
));
self.stale_inline_completion_in_menu = None;
self.active_inline_completion = Some(InlineCompletionState {
inlay_ids,
completion,
invalidation_range,
});
if self.show_inline_completions_in_menu(cx) && self.has_active_completions_menu() {
if let Some(hint) = self.inline_completion_menu_hint(window, cx) {
match self.context_menu.borrow_mut().as_mut() {
Some(CodeContextMenu::Completions(menu)) => {
menu.show_inline_completion_hint(hint);
}
_ => {}
}
}
}
cx.notify();
Some(())
}
fn inline_completion_menu_hint(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<InlineCompletionMenuHint> {
let provider = self.inline_completion_provider()?;
if self.has_active_inline_completion() {
let editor_snapshot = self.snapshot(window, cx);
let text = match &self.active_inline_completion.as_ref()?.completion {
InlineCompletion::Edit {
edits,
edit_preview,
display_mode: _,
snapshot,
} => edit_preview
.as_ref()
.and_then(|edit_preview| {
inline_completion_edit_text(&snapshot, &edits, edit_preview, true, cx)
})
.map(InlineCompletionText::Edit),
InlineCompletion::Move(target) => {
let target_point =
target.to_point(&editor_snapshot.display_snapshot.buffer_snapshot);
let target_line = target_point.row + 1;
Some(InlineCompletionText::Move(
format!("Jump to edit in line {}", target_line).into(),
))
}
};
Some(InlineCompletionMenuHint::Loaded { text: text? })
} else if provider.is_refreshing(cx) {
Some(InlineCompletionMenuHint::Loading)
} else if provider.needs_terms_acceptance(cx) {
Some(InlineCompletionMenuHint::PendingTermsAcceptance)
} else {
Some(InlineCompletionMenuHint::None)
}
}
pub fn inline_completion_provider(&self) -> Option<Arc<dyn InlineCompletionProviderHandle>> {
Some(self.inline_completion_provider.as_ref()?.provider.clone())
}
@ -5439,7 +5398,6 @@ impl Editor {
}))
}
#[cfg(any(test, feature = "test-support"))]
pub fn context_menu_visible(&self) -> bool {
self.context_menu
.borrow()
@ -5447,26 +5405,300 @@ impl Editor {
.map_or(false, |menu| menu.visible())
}
#[cfg(feature = "test-support")]
pub fn context_menu_contains_inline_completion(&self) -> bool {
fn context_menu_origin(&self) -> Option<ContextMenuOrigin> {
self.context_menu
.borrow()
.as_ref()
.map_or(false, |menu| match menu {
CodeContextMenu::Completions(menu) => {
menu.entries.borrow().first().map_or(false, |entry| {
matches!(entry, CompletionEntry::InlineCompletionHint(_))
})
}
CodeContextMenu::CodeActions(_) => false,
})
.map(|menu| menu.origin())
}
fn context_menu_origin(&self, cursor_position: DisplayPoint) -> Option<ContextMenuOrigin> {
self.context_menu
.borrow()
.as_ref()
.map(|menu| menu.origin(cursor_position))
fn edit_prediction_cursor_popover_height(&self) -> Pixels {
px(32.)
}
fn current_user_player_color(&self, cx: &mut App) -> PlayerColor {
if self.read_only(cx) {
cx.theme().players().read_only()
} else {
self.style.as_ref().unwrap().local_player
}
}
fn render_edit_prediction_cursor_popover(
&self,
max_width: Pixels,
cursor_point: Point,
style: &EditorStyle,
accept_keystroke: &gpui::Keystroke,
window: &Window,
cx: &mut Context<Editor>,
) -> Option<AnyElement> {
let provider = self.inline_completion_provider.as_ref()?;
if provider.provider.needs_terms_acceptance(cx) {
return Some(
h_flex()
.h(self.edit_prediction_cursor_popover_height())
.flex_1()
.px_2()
.gap_3()
.elevation_2(cx)
.hover(|style| style.bg(cx.theme().colors().element_hover))
.id("accept-terms")
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(cx.listener(|this, _event, window, cx| {
cx.stop_propagation();
this.toggle_zed_predict_onboarding(window, cx)
}))
.child(
h_flex()
.w_full()
.gap_2()
.child(Icon::new(IconName::ZedPredict))
.child(Label::new("Accept Terms of Service"))
.child(div().w_full())
.child(Icon::new(IconName::ArrowUpRight))
.into_any_element(),
)
.into_any(),
);
}
let is_refreshing = provider.provider.is_refreshing(cx);
fn pending_completion_container() -> Div {
h_flex().gap_3().child(Icon::new(IconName::ZedPredict))
}
let completion = match &self.active_inline_completion {
Some(completion) => self.render_edit_prediction_cursor_popover_preview(
completion,
cursor_point,
style,
cx,
)?,
None if is_refreshing => match &self.stale_inline_completion_in_menu {
Some(stale_completion) => self.render_edit_prediction_cursor_popover_preview(
stale_completion,
cursor_point,
style,
cx,
)?,
None => {
pending_completion_container().child(Label::new("...").size(LabelSize::Small))
}
},
None => pending_completion_container().child(Label::new("No Prediction")),
};
let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
let completion = completion.font(buffer_font.clone());
let completion = if is_refreshing {
completion
.with_animation(
"loading-completion",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.opacity(delta),
)
.into_any_element()
} else {
completion.into_any_element()
};
let has_completion = self.active_inline_completion.is_some();
Some(
h_flex()
.h(self.edit_prediction_cursor_popover_height())
.max_w(max_width)
.flex_1()
.px_2()
.gap_3()
.elevation_2(cx)
.child(completion)
.child(div().w_full())
.child(
h_flex()
.border_l_1()
.border_color(cx.theme().colors().border_variant)
.pl_2()
.child(
h_flex()
.font(buffer_font.clone())
.p_1()
.rounded_sm()
.children(ui::render_modifiers(
&accept_keystroke.modifiers,
PlatformStyle::platform(),
if window.modifiers() == accept_keystroke.modifiers {
Some(Color::Accent)
} else {
None
},
)),
)
.opacity(if has_completion { 1.0 } else { 0.1 })
.child(
if self
.active_inline_completion
.as_ref()
.map_or(false, |c| c.is_move())
{
div()
.child(ui::Key::new(&accept_keystroke.key, None))
.font(buffer_font.clone())
.into_any()
} else {
Label::new("Preview").color(Color::Muted).into_any_element()
},
),
)
.into_any(),
)
}
fn render_edit_prediction_cursor_popover_preview(
&self,
completion: &InlineCompletionState,
cursor_point: Point,
style: &EditorStyle,
cx: &mut Context<Editor>,
) -> Option<Div> {
use text::ToPoint as _;
fn render_relative_row_jump(
prefix: impl Into<String>,
current_row: u32,
target_row: u32,
) -> Div {
let (row_diff, arrow) = if target_row < current_row {
(current_row - target_row, IconName::ArrowUp)
} else {
(target_row - current_row, IconName::ArrowDown)
};
h_flex()
.child(
Label::new(format!("{}{}", prefix.into(), row_diff))
.color(Color::Muted)
.size(LabelSize::Small),
)
.child(Icon::new(arrow).color(Color::Muted).size(IconSize::Small))
}
match &completion.completion {
InlineCompletion::Edit {
edits,
edit_preview,
snapshot,
display_mode: _,
} => {
let first_edit_row = edits.first()?.0.start.text_anchor.to_point(&snapshot).row;
let highlighted_edits = crate::inline_completion_edit_text(
&snapshot,
&edits,
edit_preview.as_ref()?,
true,
cx,
);
let len_total = highlighted_edits.text.len();
let first_line = &highlighted_edits.text
[..highlighted_edits.text.find('\n').unwrap_or(len_total)];
let first_line_len = first_line.len();
let first_highlight_start = highlighted_edits
.highlights
.first()
.map_or(0, |(range, _)| range.start);
let drop_prefix_len = first_line
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map_or(first_highlight_start, |(ix, _)| {
ix.min(first_highlight_start)
});
let preview_text = &first_line[drop_prefix_len..];
let preview_len = preview_text.len();
let highlights = highlighted_edits
.highlights
.into_iter()
.take_until(|(range, _)| range.start > first_line_len)
.map(|(range, style)| {
(
range.start - drop_prefix_len
..(range.end - drop_prefix_len).min(preview_len),
style,
)
});
let styled_text = gpui::StyledText::new(SharedString::new(preview_text))
.with_highlights(&style.text, highlights);
let preview = h_flex()
.gap_1()
.child(styled_text)
.when(len_total > first_line_len, |parent| parent.child(""));
let left = if first_edit_row != cursor_point.row {
render_relative_row_jump("", cursor_point.row, first_edit_row)
.into_any_element()
} else {
Icon::new(IconName::ZedPredict).into_any_element()
};
Some(h_flex().gap_3().child(left).child(preview))
}
InlineCompletion::Move {
target,
range_around_target,
snapshot,
} => {
let mut highlighted_text = snapshot.highlighted_text_for_range(
range_around_target.clone(),
None,
&style.syntax,
);
let cursor_color = self.current_user_player_color(cx).cursor;
let target_offset =
text::ToOffset::to_offset(&target.text_anchor, &snapshot).saturating_sub(
text::ToOffset::to_offset(&range_around_target.start, &snapshot),
);
highlighted_text.highlights = gpui::combine_highlights(
highlighted_text.highlights,
iter::once((
target_offset..target_offset + 1,
HighlightStyle {
background_color: Some(cursor_color),
..Default::default()
},
)),
)
.collect::<Vec<_>>();
Some(
h_flex()
.gap_3()
.child(render_relative_row_jump(
"Jump ",
cursor_point.row,
target.text_anchor.to_point(&snapshot).row,
))
.when(!highlighted_text.text.is_empty(), |parent| {
parent.child(highlighted_text.to_styled_text(&style.text))
}),
)
}
}
}
fn render_context_menu(
@ -5477,13 +5709,12 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Editor>,
) -> Option<AnyElement> {
self.context_menu.borrow().as_ref().and_then(|menu| {
if menu.visible() {
Some(menu.render(style, max_height_in_lines, y_flipped, window, cx))
} else {
None
}
})
let menu = self.context_menu.borrow();
let menu = menu.as_ref()?;
if !menu.visible() {
return None;
};
Some(menu.render(style, max_height_in_lines, y_flipped, window, cx))
}
fn render_context_menu_aside(
@ -5514,7 +5745,8 @@ impl Editor {
cx.notify();
self.completion_tasks.clear();
let context_menu = self.context_menu.borrow_mut().take();
if context_menu.is_some() && !self.show_inline_completions_in_menu(cx) {
self.stale_inline_completion_in_menu.take();
if context_menu.is_some() {
self.update_visible_inline_completion(window, cx);
}
context_menu
@ -15859,7 +16091,7 @@ fn inline_completion_edit_text(
edit_preview: &EditPreview,
include_deletions: bool,
cx: &App,
) -> Option<HighlightedText> {
) -> HighlightedText {
let edits = edits
.iter()
.map(|(anchor, text)| {
@ -15870,7 +16102,7 @@ fn inline_completion_edit_text(
})
.collect::<Vec<_>>();
Some(edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx))
edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx)
}
pub fn highlight_diagnostic_message(

View file

@ -11707,10 +11707,7 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo
.entries
.borrow()
.iter()
.flat_map(|c| match c {
CompletionEntry::Match(mat) => Some(mat.string.clone()),
_ => None,
})
.map(|mat| mat.string.clone())
.collect::<Vec<String>>(),
items_out
.iter()
@ -11852,13 +11849,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
fn completion_menu_entries(menu: &CompletionsMenu) -> Vec<String> {
let entries = menu.entries.borrow();
entries
.iter()
.flat_map(|e| match e {
CompletionEntry::Match(mat) => Some(mat.string.clone()),
_ => None,
})
.collect()
entries.iter().map(|mat| mat.string.clone()).collect()
}
#[gpui::test]
@ -15469,8 +15460,7 @@ async fn assert_highlighted_edits(
&edit_preview,
include_deletions,
cx,
)
.expect("Missing highlighted edits");
);
assertion_fn(highlighted_edits, cx)
});
}

View file

@ -32,11 +32,12 @@ use gpui::{
anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, quad,
relative, size, svg, transparent_black, Action, AnyElement, App, AvailableSpace, Axis, Bounds,
ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase,
Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
Hsla, InteractiveElement, IntoElement, Length, ModifiersChangedEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
Subscription, TextRun, TextStyleRefinement, WeakEntity, Window,
Edges, Element, ElementInputHandler, Entity, FocusHandle, Focusable as _, FontId,
GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Keystroke, Length,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
StatefulInteractiveElement, Style, Styled, Subscription, TextRun, TextStyleRefinement,
WeakEntity, Window,
};
use itertools::Itertools;
use language::{
@ -525,6 +526,8 @@ impl EditorElement {
window: &mut Window,
cx: &mut Context<Editor>,
) {
editor.update_inline_completion_preview(&event.modifiers, window, cx);
let mouse_position = window.mouse_position();
if !text_hitbox.is_hovered(window) {
return;
@ -1010,12 +1013,7 @@ impl EditorElement {
layouts.push(layout);
}
let player = if editor.read_only(cx) {
cx.theme().players().read_only()
} else {
self.style.local_player
};
let player = editor.current_user_player_color(cx);
selections.push((player, layouts));
}
@ -1077,11 +1075,6 @@ impl EditorElement {
selections.extend(remote_selections.into_values());
} else if !editor.is_focused(window) && editor.show_cursor_when_unfocused {
let player = if editor.read_only(cx) {
cx.theme().players().read_only()
} else {
self.style.local_player
};
let layouts = snapshot
.buffer_snapshot
.selections_in_range(&(start_anchor..end_anchor), true)
@ -1097,6 +1090,7 @@ impl EditorElement {
)
})
.collect::<Vec<_>>();
let player = editor.current_user_player_color(cx);
selections.push((player, layouts));
}
});
@ -3157,7 +3151,7 @@ impl EditorElement {
}
#[allow(clippy::too_many_arguments)]
fn layout_context_menu(
fn layout_cursor_popovers(
&self,
line_height: Pixels,
text_hitbox: &Hitbox,
@ -3165,44 +3159,53 @@ impl EditorElement {
start_row: DisplayRow,
scroll_pixel_position: gpui::Point<Pixels>,
line_layouts: &[LineWithInvisibles],
newest_selection_head: DisplayPoint,
gutter_overshoot: Pixels,
cursor: DisplayPoint,
cursor_point: Point,
style: &EditorStyle,
window: &mut Window,
cx: &mut App,
) {
let Some(context_menu_origin) = self
.editor
.read(cx)
.context_menu_origin(newest_selection_head)
else {
let mut min_menu_height = Pixels::ZERO;
let mut max_menu_height = Pixels::ZERO;
let mut height_above_menu = Pixels::ZERO;
let height_below_menu = Pixels::ZERO;
let mut edit_prediction_popover_visible = false;
let mut context_menu_visible = false;
{
let editor = self.editor.read(cx);
if editor.has_active_completions_menu() && editor.show_inline_completions_in_menu(cx) {
height_above_menu +=
editor.edit_prediction_cursor_popover_height() + POPOVER_Y_PADDING;
edit_prediction_popover_visible = true;
}
if editor.context_menu_visible() {
if let Some(crate::ContextMenuOrigin::Cursor) = editor.context_menu_origin() {
min_menu_height += line_height * 3. + POPOVER_Y_PADDING;
max_menu_height += line_height * 12. + POPOVER_Y_PADDING;
context_menu_visible = true;
}
}
}
let visible = edit_prediction_popover_visible || context_menu_visible;
if !visible {
return;
};
}
let cursor_row_layout = &line_layouts[cursor.row().minus(start_row) as usize];
let target_position = content_origin
+ match context_menu_origin {
crate::ContextMenuOrigin::EditorPoint(display_point) => {
let cursor_row_layout =
&line_layouts[display_point.row().minus(start_row) as usize];
gpui::Point {
x: cmp::max(
px(0.),
cursor_row_layout.x_for_index(display_point.column() as usize)
- scroll_pixel_position.x,
),
y: cmp::max(
px(0.),
display_point.row().next_row().as_f32() * line_height
- scroll_pixel_position.y,
),
}
}
crate::ContextMenuOrigin::GutterIndicator(row) => {
// Context menu was spawned via a click on a gutter. Ensure it's a bit closer to the indicator than just a plain first column of the
// text field.
gpui::Point {
x: -gutter_overshoot,
y: row.next_row().as_f32() * line_height - scroll_pixel_position.y,
}
}
+ gpui::Point {
x: cmp::max(
px(0.),
cursor_row_layout.x_for_index(cursor.column() as usize)
- scroll_pixel_position.x,
),
y: cmp::max(
px(0.),
cursor.row().next_row().as_f32() * line_height - scroll_pixel_position.y,
),
};
let viewport_bounds =
@ -3211,17 +3214,241 @@ impl EditorElement {
..Default::default()
});
// If the context menu's max height won't fit below, then flip it above the line and display
// it in reverse order. If the available space above is less than below.
let unconstrained_max_height = line_height * 12. + POPOVER_Y_PADDING;
let min_height = height_above_menu + min_menu_height + height_below_menu;
let max_height = height_above_menu + max_menu_height + height_below_menu;
let Some((laid_out_popovers, y_flipped)) = self.layout_popovers_above_or_below_line(
target_position,
line_height,
min_height,
max_height,
text_hitbox,
viewport_bounds,
window,
cx,
|height, max_width_for_stable_x, y_flipped, window, cx| {
// First layout the menu to get its size - others can be at least this wide.
let context_menu = if context_menu_visible {
let menu_height = if y_flipped {
height - height_below_menu
} else {
height - height_above_menu
};
let mut element = self
.render_context_menu(line_height, menu_height, y_flipped, window, cx)
.unwrap();
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
Some((CursorPopoverType::CodeContextMenu, element, size))
} else {
None
};
let max_width = max_width_for_stable_x.max(
context_menu
.as_ref()
.map_or(px(0.), |(_, _, size)| size.width),
);
let edit_prediction = if edit_prediction_popover_visible {
let accept_keystroke: Option<Keystroke>;
// TODO: load modifier from keymap.
// `bindings_for_action_in` returns `None` in Linux, and is intermittent on macOS
#[cfg(target_os = "macos")]
{
// let bindings = window.bindings_for_action_in(
// &crate::AcceptInlineCompletion,
// &self.editor.focus_handle(cx),
// );
// let last_binding = bindings.last();
// accept_keystroke = if let Some(binding) = last_binding {
// match &binding.keystrokes() {
// // TODO: no need to clone once this logic works on linux.
// [keystroke] => Some(keystroke.clone()),
// _ => None,
// }
// } else {
// None
// };
accept_keystroke = Some(Keystroke {
modifiers: gpui::Modifiers {
alt: true,
control: false,
shift: false,
platform: false,
function: false,
},
key: "tab".to_string(),
key_char: None,
});
}
#[cfg(not(target_os = "macos"))]
{
accept_keystroke = Some(Keystroke {
modifiers: gpui::Modifiers {
alt: true,
control: false,
shift: false,
platform: false,
function: false,
},
key: "enter".to_string(),
key_char: None,
});
}
self.editor.update(cx, move |editor, cx| {
let mut element = editor.render_edit_prediction_cursor_popover(
max_width,
cursor_point,
style,
accept_keystroke.as_ref()?,
window,
cx,
)?;
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
Some((CursorPopoverType::EditPrediction, element, size))
})
} else {
None
};
vec![edit_prediction, context_menu]
.into_iter()
.flatten()
.collect::<Vec<_>>()
},
) else {
return;
};
let Some((_, menu_bounds)) = laid_out_popovers
.iter()
.find(|(x, _)| matches!(x, CursorPopoverType::CodeContextMenu))
else {
return;
};
let first_popover_bounds = laid_out_popovers[0].1;
let last_popover_bounds = laid_out_popovers[laid_out_popovers.len() - 1].1;
let mut target_bounds = if y_flipped {
Bounds::from_corners(
last_popover_bounds.origin,
first_popover_bounds.bottom_right(),
)
} else {
Bounds::from_corners(
first_popover_bounds.origin,
last_popover_bounds.bottom_right(),
)
};
target_bounds.size.width = menu_bounds.size.width;
let mut max_target_bounds = target_bounds;
max_target_bounds.size.height = max_height;
if y_flipped {
max_target_bounds.origin.y -= max_height - target_bounds.size.height;
}
let mut extend_amount = Edges::all(MENU_GAP);
if y_flipped {
extend_amount.bottom = line_height;
} else {
extend_amount.top = line_height;
}
let target_bounds = target_bounds.extend(extend_amount);
let max_target_bounds = max_target_bounds.extend(extend_amount);
self.layout_context_menu_aside(
y_flipped,
*menu_bounds,
target_bounds,
max_target_bounds,
max_menu_height,
text_hitbox,
viewport_bounds,
window,
cx,
);
}
#[allow(clippy::too_many_arguments)]
fn layout_gutter_menu(
&self,
line_height: Pixels,
text_hitbox: &Hitbox,
content_origin: gpui::Point<Pixels>,
scroll_pixel_position: gpui::Point<Pixels>,
gutter_overshoot: Pixels,
window: &mut Window,
cx: &mut App,
) {
let Some(crate::ContextMenuOrigin::GutterIndicator(gutter_row)) =
self.editor.read(cx).context_menu_origin()
else {
return;
};
// Context menu was spawned via a click on a gutter. Ensure it's a bit closer to the
// indicator than just a plain first column of the text field.
let target_position = content_origin
+ gpui::Point {
x: -gutter_overshoot,
y: gutter_row.next_row().as_f32() * line_height - scroll_pixel_position.y,
};
let min_height = line_height * 3. + POPOVER_Y_PADDING;
let max_height = line_height * 12. + POPOVER_Y_PADDING;
let viewport_bounds =
Bounds::new(Default::default(), window.viewport_size()).extend(Edges {
right: -Self::SCROLLBAR_WIDTH - MENU_GAP,
..Default::default()
});
self.layout_popovers_above_or_below_line(
target_position,
line_height,
min_height,
max_height,
text_hitbox,
viewport_bounds,
window,
cx,
move |height, _max_width_for_stable_x, y_flipped, window, cx| {
let Some(mut element) =
self.render_context_menu(line_height, height, y_flipped, window, cx)
else {
return vec![];
};
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
vec![(CursorPopoverType::CodeContextMenu, element, size)]
},
);
}
#[allow(clippy::too_many_arguments)]
fn layout_popovers_above_or_below_line(
&self,
target_position: gpui::Point<Pixels>,
line_height: Pixels,
min_height: Pixels,
max_height: Pixels,
text_hitbox: &Hitbox,
viewport_bounds: Bounds<Pixels>,
window: &mut Window,
cx: &mut App,
make_sized_popovers: impl FnOnce(
Pixels,
Pixels,
bool,
&mut Window,
&mut App,
) -> Vec<(CursorPopoverType, AnyElement, Size<Pixels>)>,
) -> Option<(Vec<(CursorPopoverType, Bounds<Pixels>)>, bool)> {
// If the max height won't fit below and there is more space above, put it above the line.
let bottom_y_when_flipped = target_position.y - line_height;
let available_above = bottom_y_when_flipped - text_hitbox.top();
let available_below = text_hitbox.bottom() - target_position.y;
let y_overflows_below = unconstrained_max_height > available_below;
let y_overflows_below = max_height > available_below;
let mut y_flipped = y_overflows_below && available_above > available_below;
let mut height = cmp::min(
unconstrained_max_height,
max_height,
if y_flipped {
available_above
} else {
@ -3229,14 +3456,14 @@ impl EditorElement {
},
);
// If less than 3 lines fit within the text bounds, instead fit within the window.
// If the min height doesn't fit within text bounds, instead fit within the window.
if height < min_height {
let available_above = bottom_y_when_flipped;
let available_below = viewport_bounds.bottom() - target_position.y;
if available_below > 3. * line_height {
if available_below > min_height {
y_flipped = false;
height = min_height;
} else if available_above > 3. * line_height {
} else if available_above > min_height {
y_flipped = true;
height = min_height;
} else if available_above > available_below {
@ -3248,82 +3475,67 @@ impl EditorElement {
}
}
let max_height_in_lines = ((height - POPOVER_Y_PADDING) / line_height).floor() as u32;
let max_width_for_stable_x = viewport_bounds.right() - target_position.x;
// TODO(mgsloan): use viewport_bounds.width as a max width when rendering menu.
let Some(mut menu_element) = self.editor.update(cx, |editor, cx| {
editor.render_context_menu(&self.style, max_height_in_lines, y_flipped, window, cx)
}) else {
return;
};
// TODO: Use viewport_bounds.width as a max width so that it doesn't get clipped on the left
// for very narrow windows.
let popovers = make_sized_popovers(height, max_width_for_stable_x, y_flipped, window, cx);
if popovers.is_empty() {
return None;
}
let menu_size = menu_element.layout_as_root(AvailableSpace::min_size(), window, cx);
let menu_position = gpui::Point {
let max_width = popovers
.iter()
.map(|(_, _, size)| size.width)
.max()
.unwrap_or_default();
let mut current_position = gpui::Point {
// Snap the right edge of the list to the right edge of the window if its horizontal bounds
// overflow. Include space for the scrollbar.
x: target_position
.x
.min((viewport_bounds.right() - menu_size.width).max(Pixels::ZERO)),
.min((viewport_bounds.right() - max_width).max(Pixels::ZERO)),
y: if y_flipped {
bottom_y_when_flipped - menu_size.height
bottom_y_when_flipped
} else {
target_position.y
},
};
window.defer_draw(menu_element, menu_position, 1);
// Layout documentation aside
let menu_bounds = Bounds::new(menu_position, menu_size);
let max_menu_size = size(menu_size.width, unconstrained_max_height);
let max_menu_bounds = if y_flipped {
Bounds::new(
point(
menu_position.x,
bottom_y_when_flipped - max_menu_size.height,
),
max_menu_size,
)
} else {
Bounds::new(target_position, max_menu_size)
};
self.layout_context_menu_aside(
text_hitbox,
y_flipped,
menu_position,
menu_bounds,
max_menu_bounds,
unconstrained_max_height,
line_height,
viewport_bounds,
window,
cx,
);
let laid_out_popovers = popovers
.into_iter()
.map(|(popover_type, element, size)| {
if y_flipped {
current_position.y -= size.height;
}
let position = current_position;
window.defer_draw(element, current_position, 1);
if !y_flipped {
current_position.y += size.height + MENU_GAP;
} else {
current_position.y -= MENU_GAP;
}
(popover_type, Bounds::new(position, size))
})
.collect::<Vec<_>>();
Some((laid_out_popovers, y_flipped))
}
#[allow(clippy::too_many_arguments)]
fn layout_context_menu_aside(
&self,
text_hitbox: &Hitbox,
y_flipped: bool,
menu_position: gpui::Point<Pixels>,
menu_bounds: Bounds<Pixels>,
max_menu_bounds: Bounds<Pixels>,
target_bounds: Bounds<Pixels>,
max_target_bounds: Bounds<Pixels>,
max_height: Pixels,
line_height: Pixels,
text_hitbox: &Hitbox,
viewport_bounds: Bounds<Pixels>,
window: &mut Window,
cx: &mut App,
) {
let mut extend_amount = Edges::all(MENU_GAP);
// Extend to include the cursored line to avoid overlapping it.
if y_flipped {
extend_amount.bottom = line_height;
} else {
extend_amount.top = line_height;
}
let target_bounds = menu_bounds.extend(extend_amount);
let max_target_bounds = max_menu_bounds.extend(extend_amount);
let available_within_viewport = target_bounds.space_within(&viewport_bounds);
let positioned_aside = if available_within_viewport.right >= MENU_ASIDE_MIN_WIDTH {
let max_width = cmp::min(
@ -3336,7 +3548,7 @@ impl EditorElement {
return;
};
aside.layout_as_root(AvailableSpace::min_size(), window, cx);
let right_position = point(target_bounds.right(), menu_position.y);
let right_position = point(target_bounds.right(), menu_bounds.origin.y);
Some((aside, right_position))
} else {
let max_size = size(
@ -3359,8 +3571,11 @@ impl EditorElement {
};
let actual_size = aside.layout_as_root(AvailableSpace::min_size(), window, cx);
let top_position = point(menu_position.x, target_bounds.top() - actual_size.height);
let bottom_position = point(menu_position.x, target_bounds.bottom());
let top_position = point(
menu_bounds.origin.x,
target_bounds.top() - actual_size.height,
);
let bottom_position = point(menu_bounds.origin.x, target_bounds.bottom());
let fit_within = |available: Edges<Pixels>, wanted: Size<Pixels>| {
// Prefer to fit on the same side of the line as the menu, then on the other side of
@ -3396,6 +3611,20 @@ impl EditorElement {
}
}
fn render_context_menu(
&self,
line_height: Pixels,
height: Pixels,
y_flipped: bool,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let max_height_in_lines = ((height - POPOVER_Y_PADDING) / line_height).floor() as u32;
self.editor.update(cx, |editor, cx| {
editor.render_context_menu(&self.style, max_height_in_lines, y_flipped, window, cx)
})
}
fn render_context_menu_aside(
&self,
max_size: Size<Pixels>,
@ -3434,12 +3663,14 @@ impl EditorElement {
let active_inline_completion = self.editor.read(cx).active_inline_completion.as_ref()?;
match &active_inline_completion.completion {
InlineCompletion::Move(target_position) => {
let target_display_point = target_position.to_display_point(editor_snapshot);
InlineCompletion::Move { target, .. } => {
let target_display_point = target.to_display_point(editor_snapshot);
if target_display_point.row().as_f32() < scroll_top {
let mut element = inline_completion_tab_indicator(
let mut element = inline_completion_accept_indicator(
"Jump to Edit",
Some(IconName::ArrowUp),
self.editor.focus_handle(cx),
window,
cx,
);
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@ -3447,9 +3678,11 @@ impl EditorElement {
element.prepaint_at(text_bounds.origin + offset, window, cx);
Some(element)
} else if (target_display_point.row().as_f32() + 1.) > scroll_bottom {
let mut element = inline_completion_tab_indicator(
let mut element = inline_completion_accept_indicator(
"Jump to Edit",
Some(IconName::ArrowDown),
self.editor.focus_handle(cx),
window,
cx,
);
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@ -3460,7 +3693,13 @@ impl EditorElement {
element.prepaint_at(text_bounds.origin + offset, window, cx);
Some(element)
} else {
let mut element = inline_completion_tab_indicator("Jump to Edit", None, cx);
let mut element = inline_completion_accept_indicator(
"Jump to Edit",
None,
self.editor.focus_handle(cx),
window,
cx,
);
let target_line_end = DisplayPoint::new(
target_display_point.row(),
@ -3520,7 +3759,13 @@ impl EditorElement {
editor.display_to_pixel_point(target_line_end, editor_snapshot, window)
})?;
let mut element = inline_completion_tab_indicator("Accept", None, cx);
let mut element = inline_completion_accept_indicator(
"Accept",
None,
self.editor.focus_handle(cx),
window,
cx,
);
element.prepaint_as_root(
text_bounds.origin + origin + point(PADDING_X, px(0.)),
@ -3535,9 +3780,13 @@ impl EditorElement {
EditDisplayMode::DiffPopover => {}
}
let highlighted_edits = edit_preview.as_ref().and_then(|edit_preview| {
crate::inline_completion_edit_text(&snapshot, edits, edit_preview, false, cx)
})?;
let highlighted_edits = crate::inline_completion_edit_text(
&snapshot,
edits,
edit_preview.as_ref()?,
false,
cx,
);
let line_count = highlighted_edits.text.lines().count();
@ -3558,8 +3807,7 @@ impl EditorElement {
.width
};
let styled_text = gpui::StyledText::new(highlighted_edits.text.clone())
.with_highlights(&style.text, highlighted_edits.highlights);
let styled_text = highlighted_edits.to_styled_text(&style.text);
let mut element = div()
.bg(cx.theme().colors().editor_background)
@ -5548,17 +5796,33 @@ fn header_jump_data(
}
}
fn inline_completion_tab_indicator(
fn inline_completion_accept_indicator(
label: impl Into<SharedString>,
icon: Option<IconName>,
focus_handle: FocusHandle,
window: &Window,
cx: &App,
) -> AnyElement {
let tab_kbd = h_flex()
let bindings = window.bindings_for_action_in(&crate::AcceptInlineCompletion, &focus_handle);
let Some(accept_keystroke) = bindings
.last()
.and_then(|binding| binding.keystrokes().first())
else {
return div().into_any();
};
let accept_key = h_flex()
.px_0p5()
.font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
.text_size(TextSize::XSmall.rems(cx))
.text_color(cx.theme().colors().text)
.child("tab");
.gap_1()
.children(ui::render_modifiers(
&accept_keystroke.modifiers,
PlatformStyle::platform(),
Some(Color::Default),
))
.child(accept_keystroke.key.clone());
let padding_right = if icon.is_some() { px(4.) } else { px(8.) };
@ -5572,7 +5836,7 @@ fn inline_completion_tab_indicator(
.border_color(cx.theme().colors().text_accent.opacity(0.8))
.rounded_md()
.shadow_sm()
.child(tab_kbd)
.child(accept_key)
.child(Label::new(label).size(LabelSize::Small))
.when_some(icon, |element, icon| {
element.child(
@ -7059,8 +7323,11 @@ impl Element for EditorElement {
);
let mut code_actions_indicator = None;
if let Some(newest_selection_head) = newest_selection_head {
let newest_selection_point =
newest_selection_head.to_point(&snapshot.display_snapshot);
if (start_row..end_row).contains(&newest_selection_head.row()) {
self.layout_context_menu(
self.layout_cursor_popovers(
line_height,
&text_hitbox,
content_origin,
@ -7068,7 +7335,8 @@ impl Element for EditorElement {
scroll_pixel_position,
&line_layouts,
newest_selection_head,
gutter_dimensions.width - gutter_dimensions.left_padding,
newest_selection_point,
&style,
window,
cx,
);
@ -7113,6 +7381,16 @@ impl Element for EditorElement {
}
}
self.layout_gutter_menu(
line_height,
&text_hitbox,
content_origin,
scroll_pixel_position,
gutter_dimensions.width - gutter_dimensions.left_padding,
window,
cx,
);
let test_indicators = if gutter_settings.runnables {
self.layout_run_indicators(
line_height,
@ -7994,6 +8272,11 @@ impl HighlightedRange {
}
}
enum CursorPopoverType {
CodeContextMenu,
EditPrediction,
}
pub fn scale_vertical_mouse_autoscroll_delta(delta: Pixels) -> f32 {
(delta.pow(1.5) / 100.0).into()
}

View file

@ -1253,7 +1253,7 @@ fn apply_hint_update(
editor.inlay_hint_cache.version += 1;
}
if displayed_inlays_changed {
editor.splice_inlays(to_remove, to_insert, cx)
editor.splice_inlays(&to_remove, to_insert, cx)
}
}

View file

@ -304,8 +304,8 @@ fn assert_editor_active_move_completion(
.as_ref()
.expect("editor has no active completion");
if let InlineCompletion::Move(anchor) = &completion_state.completion {
assert(editor.buffer().read(cx).snapshot(cx), *anchor);
if let InlineCompletion::Move { target, .. } = &completion_state.completion {
assert(editor.buffer().read(cx).snapshot(cx), *target);
} else {
panic!("expected move completion");
}

View file

@ -864,7 +864,7 @@ mod tests {
})
.collect();
let snapshot = display_map.update(cx, |map, cx| {
map.splice_inlays(Vec::new(), inlays, cx);
map.splice_inlays(&[], inlays, cx);
map.snapshot(cx)
});

View file

@ -26,7 +26,7 @@ use fs::MTime;
use futures::channel::oneshot;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, EventEmitter, HighlightStyle, Pixels,
SharedString, Task, TaskLabel, Window,
SharedString, StyledText, Task, TaskLabel, TextStyle, Window,
};
use lsp::LanguageServerId;
use parking_lot::Mutex;
@ -617,6 +617,11 @@ impl HighlightedText {
);
highlighted_text.build()
}
pub fn to_styled_text(&self, default_style: &TextStyle) -> StyledText {
gpui::StyledText::new(self.text.clone())
.with_highlights(default_style, self.highlights.iter().cloned())
}
}
impl HighlightedTextBuilder {

View file

@ -1,7 +1,9 @@
#![allow(missing_docs)]
use crate::PlatformStyle;
use crate::{h_flex, prelude::*, Icon, IconName, IconSize};
use gpui::{relative, Action, App, FocusHandle, IntoElement, Keystroke, Window};
use gpui::{
relative, Action, AnyElement, App, FocusHandle, IntoElement, Keystroke, Modifiers, Window,
};
#[derive(Debug, IntoElement, Clone)]
pub struct KeyBinding {
@ -41,30 +43,6 @@ impl KeyBinding {
Some(Self::new(key_binding))
}
fn icon_for_key(&self, keystroke: &Keystroke) -> Option<IconName> {
match keystroke.key.as_str() {
"left" => Some(IconName::ArrowLeft),
"right" => Some(IconName::ArrowRight),
"up" => Some(IconName::ArrowUp),
"down" => Some(IconName::ArrowDown),
"backspace" => Some(IconName::Backspace),
"delete" => Some(IconName::Delete),
"return" => Some(IconName::Return),
"enter" => Some(IconName::Return),
"tab" => Some(IconName::Tab),
"space" => Some(IconName::Space),
"escape" => Some(IconName::Escape),
"pagedown" => Some(IconName::PageDown),
"pageup" => Some(IconName::PageUp),
"shift" if self.platform_style == PlatformStyle::Mac => Some(IconName::Shift),
"control" if self.platform_style == PlatformStyle::Mac => Some(IconName::Control),
"platform" if self.platform_style == PlatformStyle::Mac => Some(IconName::Command),
"function" if self.platform_style == PlatformStyle::Mac => Some(IconName::Control),
"alt" if self.platform_style == PlatformStyle::Mac => Some(IconName::Option),
_ => None,
}
}
pub fn new(key_binding: gpui::KeyBinding) -> Self {
Self {
key_binding,
@ -96,63 +74,148 @@ impl RenderOnce for KeyBinding {
.gap(DynamicSpacing::Base04.rems(cx))
.flex_none()
.children(self.key_binding.keystrokes().iter().map(|keystroke| {
let key_icon = self.icon_for_key(keystroke);
h_flex()
.flex_none()
.py_0p5()
.rounded_sm()
.text_color(cx.theme().colors().text_muted)
.when(keystroke.modifiers.function, |el| {
match self.platform_style {
PlatformStyle::Mac => el.child(Key::new("fn")),
PlatformStyle::Linux | PlatformStyle::Windows => {
el.child(Key::new("Fn")).child(Key::new("+"))
}
}
})
.when(keystroke.modifiers.control, |el| {
match self.platform_style {
PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Control)),
PlatformStyle::Linux | PlatformStyle::Windows => {
el.child(Key::new("Ctrl")).child(Key::new("+"))
}
}
})
.when(keystroke.modifiers.alt, |el| match self.platform_style {
PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Option)),
PlatformStyle::Linux | PlatformStyle::Windows => {
el.child(Key::new("Alt")).child(Key::new("+"))
}
})
.when(keystroke.modifiers.platform, |el| {
match self.platform_style {
PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Command)),
PlatformStyle::Linux => {
el.child(Key::new("Super")).child(Key::new("+"))
}
PlatformStyle::Windows => {
el.child(Key::new("Win")).child(Key::new("+"))
}
}
})
.when(keystroke.modifiers.shift, |el| match self.platform_style {
PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Shift)),
PlatformStyle::Linux | PlatformStyle::Windows => {
el.child(Key::new("Shift")).child(Key::new("+"))
}
})
.map(|el| match key_icon {
Some(icon) => el.child(KeyIcon::new(icon)),
None => el.child(Key::new(keystroke.key.to_uppercase())),
})
.children(render_modifiers(
&keystroke.modifiers,
self.platform_style,
None,
))
.map(|el| el.child(render_key(&keystroke, self.platform_style, None)))
}))
}
}
pub fn render_key(
keystroke: &Keystroke,
platform_style: PlatformStyle,
color: Option<Color>,
) -> AnyElement {
let key_icon = icon_for_key(keystroke, platform_style);
match key_icon {
Some(icon) => KeyIcon::new(icon, color).into_any_element(),
None => Key::new(
if keystroke.key.len() > 1 {
keystroke.key.clone()
} else {
keystroke.key.to_uppercase()
},
color,
)
.into_any_element(),
}
}
fn icon_for_key(keystroke: &Keystroke, platform_style: PlatformStyle) -> Option<IconName> {
match keystroke.key.as_str() {
"left" => Some(IconName::ArrowLeft),
"right" => Some(IconName::ArrowRight),
"up" => Some(IconName::ArrowUp),
"down" => Some(IconName::ArrowDown),
"backspace" => Some(IconName::Backspace),
"delete" => Some(IconName::Delete),
"return" => Some(IconName::Return),
"enter" => Some(IconName::Return),
// "tab" => Some(IconName::Tab),
"space" => Some(IconName::Space),
"escape" => Some(IconName::Escape),
"pagedown" => Some(IconName::PageDown),
"pageup" => Some(IconName::PageUp),
"shift" if platform_style == PlatformStyle::Mac => Some(IconName::Shift),
"control" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
"platform" if platform_style == PlatformStyle::Mac => Some(IconName::Command),
"function" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
"alt" if platform_style == PlatformStyle::Mac => Some(IconName::Option),
_ => None,
}
}
pub fn render_modifiers(
modifiers: &Modifiers,
platform_style: PlatformStyle,
color: Option<Color>,
) -> impl Iterator<Item = AnyElement> {
enum KeyOrIcon {
Key(&'static str),
Icon(IconName),
}
struct Modifier {
enabled: bool,
mac: KeyOrIcon,
linux: KeyOrIcon,
windows: KeyOrIcon,
}
let table = {
use KeyOrIcon::*;
[
Modifier {
enabled: modifiers.function,
mac: Icon(IconName::Control),
linux: Key("Fn"),
windows: Key("Fn"),
},
Modifier {
enabled: modifiers.control,
mac: Icon(IconName::Control),
linux: Key("Ctrl"),
windows: Key("Ctrl"),
},
Modifier {
enabled: modifiers.alt,
mac: Icon(IconName::Option),
linux: Key("Alt"),
windows: Key("Alt"),
},
Modifier {
enabled: modifiers.platform,
mac: Icon(IconName::Command),
linux: Key("Super"),
windows: Key("Win"),
},
Modifier {
enabled: modifiers.shift,
mac: Icon(IconName::Shift),
linux: Key("Shift"),
windows: Key("Shift"),
},
]
};
table
.into_iter()
.flat_map(move |modifier| {
if modifier.enabled {
match platform_style {
PlatformStyle::Mac => Some(modifier.mac),
PlatformStyle::Linux => Some(modifier.linux)
.into_iter()
.chain(Some(KeyOrIcon::Key("+")))
.next(),
PlatformStyle::Windows => Some(modifier.windows)
.into_iter()
.chain(Some(KeyOrIcon::Key("+")))
.next(),
}
} else {
None
}
})
.map(move |key_or_icon| match key_or_icon {
KeyOrIcon::Key(key) => Key::new(key, color).into_any_element(),
KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).into_any_element(),
})
}
#[derive(IntoElement)]
pub struct Key {
key: SharedString,
color: Option<Color>,
}
impl RenderOnce for Key {
@ -174,33 +237,37 @@ impl RenderOnce for Key {
.h(rems_from_px(14.))
.text_ui(cx)
.line_height(relative(1.))
.text_color(cx.theme().colors().text_muted)
.text_color(self.color.unwrap_or(Color::Muted).color(cx))
.child(self.key.clone())
}
}
impl Key {
pub fn new(key: impl Into<SharedString>) -> Self {
Self { key: key.into() }
pub fn new(key: impl Into<SharedString>, color: Option<Color>) -> Self {
Self {
key: key.into(),
color,
}
}
}
#[derive(IntoElement)]
pub struct KeyIcon {
icon: IconName,
color: Option<Color>,
}
impl RenderOnce for KeyIcon {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
Icon::new(self.icon)
.size(IconSize::XSmall)
.color(Color::Muted)
.color(self.color.unwrap_or(Color::Muted))
}
}
impl KeyIcon {
pub fn new(icon: IconName) -> Self {
Self { icon }
pub fn new(icon: IconName, color: Option<Color>) -> Self {
Self { icon, color }
}
}

View file

@ -3441,7 +3441,7 @@ mod test {
let range = editor.selections.newest_anchor().range();
let inlay_text = " field: int,\n field2: string\n field3: float";
let inlay = Inlay::inline_completion(1, range.start, inlay_text);
editor.splice_inlays(vec![], vec![inlay], cx);
editor.splice_inlays(&[], vec![inlay], cx);
});
cx.simulate_keystrokes("j");
@ -3473,7 +3473,7 @@ mod test {
snapshot.anchor_after(Point::new(0, snapshot.line_len(MultiBufferRow(0))));
let inlay_text = " hint";
let inlay = Inlay::inline_completion(1, end_of_line, inlay_text);
editor.splice_inlays(vec![], vec![inlay], cx);
editor.splice_inlays(&[], vec![inlay], cx);
});
cx.simulate_keystrokes("$");
cx.assert_state(

View file

@ -1530,6 +1530,16 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
.log_err()
.flatten()
else {
this.update(&mut cx, |this, cx| {
if this.pending_completions[0].id == pending_completion_id {
this.pending_completions.remove(0);
} else {
this.pending_completions.clear();
}
cx.notify();
})
.ok();
return;
};