diff --git a/Cargo.toml b/Cargo.toml index 6b3e3859..c8e545ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ sum-tree = { version = "0.2.0", package = "zed-sum-tree" } anyhow = "1" log = "0.4" -lsp-types = "0.97.0" +lsp-types = { version = "0.97.0", features = ["proposed"] } notify = "7.0.0" raw-window-handle = "0.6.2" ropey = { version = "=2.0.0-beta.1", features = [ diff --git a/crates/story/examples/editor.rs b/crates/story/examples/editor.rs index 924a210b..09043ed8 100644 --- a/crates/story/examples/editor.rs +++ b/crates/story/examples/editor.rs @@ -27,7 +27,8 @@ use gpui_component_assets::Assets; use gpui_component_story::Open; use lsp_types::{ CodeAction, CodeActionKind, CompletionContext, CompletionItem, CompletionResponse, - CompletionTextEdit, InsertReplaceEdit, TextEdit, WorkspaceEdit, + CompletionTextEdit, InlineCompletionContext, InlineCompletionItem, InlineCompletionResponse, + InsertReplaceEdit, InsertTextFormat, TextEdit, WorkspaceEdit, }; fn init() { @@ -187,6 +188,45 @@ impl CompletionProvider for ExampleLspStore { }) } + fn inline_completion( + &self, + rope: &Rope, + offset: usize, + _trigger: InlineCompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task> { + let rope = rope.clone(); + cx.background_spawn(async move { + // Get the current line text before cursor using RopeExt + let point = rope.offset_to_point(offset); + let line_start = rope.line_start_offset(point.row); + let current_line = rope.slice(line_start..offset).to_string(); + + // Simple pattern matching for demo + let suggestion = + if current_line.trim_start().starts_with("fn ") && !current_line.contains('{') { + Some("() {\n // Write your code here..\n}".into()) + } else { + None + }; + + if let Some(insert_text) = suggestion { + Ok(InlineCompletionResponse::Array(vec![ + InlineCompletionItem { + insert_text, + filter_text: None, + range: None, + command: None, + insert_text_format: Some(InsertTextFormat::SNIPPET), + }, + ])) + } else { + Ok(InlineCompletionResponse::Array(vec![])) + } + }) + } + fn is_completion_trigger( &self, _offset: usize, diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 143874ac..4e7941cc 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -558,6 +558,91 @@ impl TextElement { (line_number_width, line_number_len) } + /// Compute inline completion ghost lines for rendering. + /// + /// Returns (first_line, ghost_lines) where: + /// - first_line: Shaped text for the first line (goes after cursor on same line) + /// - ghost_lines: Shaped lines for subsequent lines (shift content down) + fn layout_inline_completion( + state: &InputState, + visible_range: &Range, + font_size: Pixels, + window: &mut Window, + cx: &App, + ) -> (Option, Vec) { + // Must be focused to show inline completion + if !state.focus_handle.is_focused(window) { + return (None, vec![]); + } + + let Some(completion_item) = state.inline_completion.item.as_ref() else { + return (None, vec![]); + }; + + // Get cursor row from cursor position + let cursor_row = state.cursor_position().line as usize; + + // Only show if cursor row is visible + if cursor_row < visible_range.start || cursor_row >= visible_range.end { + return (None, vec![]); + } + + let completion_text = &completion_item.insert_text; + let completion_color = cx.theme().muted_foreground.opacity(0.5); + + let text_style = window.text_style(); + let font = text_style.font(); + + let lines: Vec<&str> = completion_text.split('\n').collect(); + if lines.is_empty() { + return (None, vec![]); + } + + // Shape first line (goes after cursor) + let first_text: SharedString = lines[0].to_string().into(); + let first_line = if !first_text.is_empty() { + let first_run = TextRun { + len: first_text.len(), + font: font.clone(), + color: completion_color, + background_color: None, + underline: None, + strikethrough: None, + }; + Some( + window + .text_system() + .shape_line(first_text, font_size, &[first_run], None), + ) + } else { + None + }; + + // Shape ghost lines (lines 2+ that shift content down) + let ghost_lines: Vec = lines[1..] + .iter() + .map(|line_text| { + let text: SharedString = line_text.to_string().into(); + let len = text.len().max(1); // Ensure at least 1 for empty lines + let run = TextRun { + len, + font: font.clone(), + color: completion_color, + background_color: None, + underline: None, + strikethrough: None, + }; + // Use space for empty lines so they take up height + let shaped_text = if text.is_empty() { " ".into() } else { text }; + window + .text_system() + .shape_line(shaped_text, font_size, &[run], None) + }) + .collect(); + + (first_line, ghost_lines) + } + fn layout_lines( state: &InputState, display_text: &Rope, @@ -725,6 +810,22 @@ pub(super) struct PrepaintState { hover_definition_hitbox: Option, indent_guides_path: Option>, bounds: Bounds, + // Inline completion rendering data + /// Shaped ghost lines to paint after cursor row (completion lines 2+) + ghost_lines: Vec, + /// First line of inline completion (painted after cursor on same line) + ghost_first_line: Option, + ghost_lines_height: Pixels, +} + +impl PrepaintState { + /// Returns cursor bounds adjusted for scroll offset, if available. + fn cursor_bounds_with_scroll(&self) -> Option> { + self.cursor_bounds.map(|mut bounds| { + bounds.origin.y += self.cursor_scroll_offset.y; + bounds + }) + } } impl IntoElement for TextElement { @@ -995,6 +1096,16 @@ impl Element for TextElement { } last_layout.lines = Rc::new(lines); + let (ghost_first_line, ghost_lines) = Self::layout_inline_completion( + state, + &last_layout.visible_range, + text_size, + window, + cx, + ); + let ghost_line_count = ghost_lines.len(); + let ghost_lines_height = ghost_line_count as f32 * line_height; + let total_wrapped_lines = state.text_wrapper.len(); let empty_bottom_height = if state.mode.is_code_editor() { bounds @@ -1012,7 +1123,7 @@ impl Element for TextElement { } else { longest_line_width }, - (total_wrapped_lines as f32 * line_height + empty_bottom_height) + (total_wrapped_lines as f32 * line_height + empty_bottom_height + ghost_lines_height) .max(bounds.size.height), ); @@ -1122,6 +1233,9 @@ impl Element for TextElement { hover_definition_hitbox, document_color_paths, indent_guides_path, + ghost_first_line, + ghost_lines, + ghost_lines_height, } } @@ -1246,21 +1360,49 @@ impl Element for TextElement { window.paint_path(path.clone(), *color); } - // Paint text + // Paint text with inline completion ghost line support let mut offset_y = mask_offset_y + invisible_top_padding; - for line in prepaint.last_layout.lines.iter() { + let ghost_lines = &prepaint.ghost_lines; + let has_ghost_lines = !ghost_lines.is_empty(); + + for (ix, line) in prepaint.last_layout.lines.iter().enumerate() { + let row = visible_range.start + ix; let p = point( origin.x + prepaint.last_layout.line_number_width, origin.y + offset_y, ); + + // Paint the actual line _ = line.paint(p, line_height, window, cx); offset_y += line.size(line_height).height; + + // After the cursor row, paint ghost lines (which shifts subsequent content down) + if has_ghost_lines && Some(row) == prepaint.current_row { + let ghost_x = origin.x + prepaint.last_layout.line_number_width; + + for ghost_line in ghost_lines { + let ghost_p = point(ghost_x, origin.y + offset_y); + + // Paint semi-transparent background for ghost line + let ghost_bounds = Bounds::new( + ghost_p, + size( + bounds.size.width - prepaint.last_layout.line_number_width, + line_height, + ), + ); + window.paint_quad(fill(ghost_bounds, cx.theme().editor_background())); + + // Paint ghost line text + _ = ghost_line.paint(ghost_p, line_height, window, cx); + offset_y += line_height; + } + } } // Paint blinking cursor if focused && show_cursor { - if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() { - cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y; + if let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() { window.paint_quad(fill(cursor_bounds, cx.theme().caret)); } } @@ -1270,13 +1412,12 @@ impl Element for TextElement { if let Some(line_numbers) = prepaint.line_numbers.as_ref() { offset_y += invisible_top_padding; - // Paint line number background window.paint_quad(fill( Bounds { origin: input_bounds.origin, size: size( prepaint.last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN, - input_bounds.size.height, + input_bounds.size.height + prepaint.ghost_lines_height, ), }, cx.theme().editor_background(), @@ -1304,6 +1445,11 @@ impl Element for TextElement { _ = line.paint(p, line_height, window, cx); offset_y += line_height; } + + // Add ghost line height after cursor row for line numbers alignment + if !prepaint.ghost_lines.is_empty() && prepaint.current_row.is_some() { + offset_y += prepaint.ghost_lines_height; + } } } @@ -1324,6 +1470,23 @@ impl Element for TextElement { window.set_cursor_style(gpui::CursorStyle::PointingHand, &hitbox); } + // Paint inline completion first line suffix (after cursor on same line) + if focused { + if let Some(first_line) = &prepaint.ghost_first_line { + if let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() { + let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width; + let p = point(first_line_x, cursor_bounds.origin.y); + + // Paint background to cover any existing text + let bg_bounds = Bounds::new(p, size(first_line.width + px(4.), line_height)); + window.paint_quad(fill(bg_bounds, cx.theme().editor_background())); + + // Paint first line completion text + _ = first_line.paint(p, line_height, window, cx); + } + } + } + self.paint_mouse_listeners(window, cx); } } diff --git a/crates/ui/src/input/indent.rs b/crates/ui/src/input/indent.rs index 0ddaf69c..489cdee7 100644 --- a/crates/ui/src/input/indent.rs +++ b/crates/ui/src/input/indent.rs @@ -222,6 +222,10 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + // First, try to accept inline completion if present + if self.accept_inline_completion(window, cx) { + return; + } self.indent(false, window, cx); } diff --git a/crates/ui/src/input/lsp/completions.rs b/crates/ui/src/input/lsp/completions.rs index 108ccea5..7aa1756f 100644 --- a/crates/ui/src/input/lsp/completions.rs +++ b/crates/ui/src/input/lsp/completions.rs @@ -1,14 +1,21 @@ use anyhow::Result; use gpui::{Context, EntityInputHandler, Task, Window}; -use lsp_types::{request::Completion, CompletionContext, CompletionItem, CompletionResponse}; +use lsp_types::{ + CompletionContext, CompletionItem, CompletionResponse, InlineCompletionContext, + InlineCompletionItem, InlineCompletionResponse, InlineCompletionTriggerKind, + request::Completion, +}; use ropey::Rope; -use std::{cell::RefCell, ops::Range, rc::Rc}; +use std::{cell::RefCell, ops::Range, rc::Rc, time::Duration}; use crate::input::{ - popovers::{CompletionMenu, ContextMenu}, InputState, + popovers::{CompletionMenu, ContextMenu}, }; +/// Default debounce duration for inline completions. +const DEFAULT_INLINE_COMPLETION_DEBOUNCE: Duration = Duration::from_millis(300); + /// A trait for providing code completions based on the current input state and context. pub trait CompletionProvider { /// Fetches completions based on the given byte offset. @@ -27,6 +34,39 @@ pub trait CompletionProvider { cx: &mut Context, ) -> Task>; + /// Fetches an inline completion suggestion for the given position. + /// + /// This is called after a debounce period when the user stops typing. + /// The provider can analyze the text and cursor position to determine + /// what inline completion suggestion to show. + /// + /// + /// # Arguments + /// * `rope` - The current text content + /// * `offset` - The cursor position in bytes + /// + /// textDocument/inlineCompletion + /// + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_inlineCompletion + fn inline_completion( + &self, + _rope: &Rope, + _offset: usize, + _trigger: InlineCompletionContext, + _window: &mut Window, + _cx: &mut Context, + ) -> Task> { + Task::ready(Ok(InlineCompletionResponse::Array(vec![]))) + } + + /// Returns the debounce duration for inline completions. + /// + /// Default: 300ms + #[inline] + fn inline_completion_debounce(&self) -> Duration { + DEFAULT_INLINE_COMPLETION_DEBOUNCE + } + fn resolve_completions( &self, _completion_indices: Vec, @@ -47,6 +87,22 @@ pub trait CompletionProvider { ) -> bool; } +pub(crate) struct InlineCompletion { + /// Completion item to display as an inline completion suggestion + pub(crate) item: Option, + /// Task for debouncing inline completion requests + pub(crate) task: Task>, +} + +impl Default for InlineCompletion { + fn default() -> Self { + Self { + item: None, + task: Task::ready(Ok(InlineCompletionResponse::Array(vec![]))), + } + } +} + impl InputState { pub(crate) fn handle_completion_trigger( &mut self, @@ -63,6 +119,10 @@ impl InputState { return; }; + // Always schedule inline completion (debounced). + // It will check if menu is open before showing the suggestion. + self.schedule_inline_completion(window, cx); + let start = range.end; let new_offset = self.cursor(); @@ -145,4 +205,105 @@ impl InputState { Ok(()) }); } + + /// Schedule an inline completion request after debouncing. + pub(crate) fn schedule_inline_completion( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + // Clear any existing inline completion on text change + self.clear_inline_completion(cx); + + let Some(provider) = self.lsp.completion_provider.clone() else { + return; + }; + + let offset = self.cursor(); + let text = self.text.clone(); + let debounce = provider.inline_completion_debounce(); + + self.inline_completion.task = cx.spawn_in(window, async move |editor, cx| { + // Debounce: wait before fetching to avoid unnecessary requests while typing + smol::Timer::after(debounce).await; + + // Now fetch the inline completion after the debounce period + let task = editor.update_in(cx, |editor, window, cx| { + // Check if cursor has moved during debounce + if editor.cursor() != offset { + return None; + } + + // Don't fetch if completion menu is open + if editor.is_context_menu_open(cx) { + return None; + } + + let trigger = InlineCompletionContext { + trigger_kind: InlineCompletionTriggerKind::Automatic, + selected_completion_info: None, + }; + + Some(provider.inline_completion(&text, offset, trigger, window, cx)) + })?; + + let Some(task) = task else { + return Ok(InlineCompletionResponse::Array(vec![])); + }; + + let response = task.await?; + + editor.update_in(cx, |editor, _window, cx| { + // Only apply if cursor still hasn't moved + if editor.cursor() != offset { + return; + } + + // Don't show if completion menu opened while we were fetching + if editor.is_context_menu_open(cx) { + return; + } + + if let Some(item) = match response.clone() { + InlineCompletionResponse::Array(items) => items.into_iter().next(), + InlineCompletionResponse::List(comp_list) => comp_list.items.into_iter().next(), + } { + editor.inline_completion.item = Some(item); + cx.notify(); + } + })?; + + Ok(response) + }); + } + + /// Check if an inline completion suggestion is currently displayed. + #[inline] + pub(crate) fn has_inline_completion(&self) -> bool { + self.inline_completion.item.is_some() + } + + /// Clear the inline completion suggestion. + pub(crate) fn clear_inline_completion(&mut self, cx: &mut Context) { + self.inline_completion = InlineCompletion::default(); + cx.notify(); + } + + /// Accept the inline completion, inserting it at the cursor position. + /// Returns true if a completion was accepted, false if there was none. + pub(crate) fn accept_inline_completion( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(completion_item) = self.inline_completion.item.take() else { + return false; + }; + + let cursor = self.cursor(); + let range_utf16 = self.range_to_utf16(&(cursor..cursor)); + let completion_text = completion_item.insert_text; + self.replace_text_in_range_silent(Some(range_utf16), &completion_text, window, cx); + true + } } diff --git a/crates/ui/src/input/movement.rs b/crates/ui/src/input/movement.rs index 76eb969d..a63549d1 100644 --- a/crates/ui/src/input/movement.rs +++ b/crates/ui/src/input/movement.rs @@ -51,6 +51,7 @@ impl InputState { self.pause_blink_cursor(cx); self.update_preferred_column(); self.hide_context_menu(cx); + self.clear_inline_completion(cx); cx.notify() } diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 3dc987c2..51b6642e 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -31,7 +31,7 @@ use crate::input::{ search::{self, SearchPanel}, text_wrapper::LineLayout, }; -use crate::input::{RopeExt as _, Selection}; +use crate::input::{InlineCompletion, RopeExt as _, Selection}; use crate::{Root, history::History}; use crate::{highlighter::DiagnosticSet, input::text_wrapper::LineItem}; @@ -331,6 +331,7 @@ pub struct InputState { _subscriptions: Vec, pub(super) _context_menu_task: Task>, + pub(super) inline_completion: InlineCompletion, } impl EventEmitter for InputState {} @@ -409,6 +410,7 @@ impl InputState { _subscriptions, _context_menu_task: Task::ready(Ok(())), _pending_update: false, + inline_completion: InlineCompletion::default(), } } @@ -1142,6 +1144,11 @@ impl InputState { return; } + // Clear inline completion on enter (user chose not to accept it) + if self.has_inline_completion() { + self.clear_inline_completion(cx); + } + if self.mode.is_multi_line() { // Get current line indent let indent = if self.mode.is_code_editor() { @@ -1175,6 +1182,12 @@ impl InputState { return; } + // Clear inline completion on escape + if self.has_inline_completion() { + self.clear_inline_completion(cx); + return; // Consume the escape, don't propagate + } + if self.ime_marked_range.is_some() { self.unmark_text(window, cx); } @@ -1192,6 +1205,9 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + // Clear inline completion on any mouse interaction + self.clear_inline_completion(cx); + // If there have IME marked range and is empty (Means pressed Esc to abort IME typing) // Clear the marked range. if let Some(ime_marked_range) = &self.ime_marked_range { @@ -1596,6 +1612,8 @@ impl InputState { /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context) { + self.clear_inline_completion(cx); + let offset = offset.clamp(0, self.text.len()); if self.selection_reversed { self.selected_range.start = offset @@ -1697,6 +1715,7 @@ impl InputState { self.hover_popover = None; self.diagnostic_popover = None; self.context_menu = None; + self.clear_inline_completion(cx); self.blink_cursor.update(cx, |cursor, cx| { cursor.stop(cx); });