Merge branch 'feature/ide-code-completion' into 'master'
Add IntelliSense with documentation panel to code editor See merge request samnasbo/shared/galaxy!4
This commit is contained in:
@@ -216,6 +216,27 @@ if FeatureFlag::YourNewFeature.is_enabled() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Code Editor IntelliSense (LSP Completion)
|
||||||
|
|
||||||
|
The code editor has full LSP-powered autocompletion with documentation resolution:
|
||||||
|
|
||||||
|
**Key files:**
|
||||||
|
- `app/src/code/completion.rs` — Completion state, rendering (menu + docs panel), resolve logic
|
||||||
|
- `app/src/code/local_code_editor.rs` — Keybindings and action handling
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Auto-completes as you type (triggered by alphanumeric/underscore with 50ms debounce)
|
||||||
|
- Trigger characters: `.` and `::` fire immediately
|
||||||
|
- Manual trigger: `Ctrl+Alt+Space`
|
||||||
|
- Keyboard navigation: Up/Down to select, Tab/Enter to confirm
|
||||||
|
- Mouse: hover an item to select it and show docs, click to confirm
|
||||||
|
- Documentation panel appears beside the menu when the LSP returns docs for the selected item (via `completionItem/resolve`)
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- `CompletionState::Showing` holds items, filtered indices, per-item `MouseStateHandle`s, and resolved docs
|
||||||
|
- `resolve_selected_completion_docs()` sends `completionItem/resolve` to the LSP server
|
||||||
|
- The docs panel renders markdown via `FormattedTextElement` in a scrollable container beside the menu
|
||||||
|
|
||||||
### Exhaustive Matching
|
### Exhaustive Matching
|
||||||
|
|
||||||
When adding/editing match statements, avoid using the wildcard _ when at all possible. Exhaustive matching is helpful for ensuring that all variants are handled, especially when adding new variants to enums in the future.
|
When adding/editing match statements, avoid using the wildcard _ when at all possible. Exhaustive matching is helpful for ensuring that all variants are handled, especially when adding new variants to enums in the future.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use galaxy_core::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
use galaxy_core::features::FeatureFlag;
|
||||||
|
|
||||||
let mut state = ChannelState::new(
|
let mut state = ChannelState::new(
|
||||||
Channel::Oss,
|
Channel::Oss,
|
||||||
ChannelConfig {
|
ChannelConfig {
|
||||||
@@ -25,6 +27,12 @@ fn main() -> Result<()> {
|
|||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
state = state.with_additional_features(galaxy_core::features::DEBUG_FLAGS);
|
state = state.with_additional_features(galaxy_core::features::DEBUG_FLAGS);
|
||||||
}
|
}
|
||||||
|
state = state.with_additional_features(&[
|
||||||
|
FeatureFlag::LspCompletion,
|
||||||
|
FeatureFlag::LspCodeActions,
|
||||||
|
FeatureFlag::LspRename,
|
||||||
|
FeatureFlag::LspSignatureHelp,
|
||||||
|
]);
|
||||||
ChannelState::set(state);
|
ChannelState::set(state);
|
||||||
|
|
||||||
galaxy::run()
|
galaxy::run()
|
||||||
|
|||||||
+339
-21
@@ -5,23 +5,37 @@ use galaxy_core::features::FeatureFlag;
|
|||||||
use galaxy_core::ui::appearance::Appearance;
|
use galaxy_core::ui::appearance::Appearance;
|
||||||
use galaxy_core::ui::theme::color::internal_colors;
|
use galaxy_core::ui::theme::color::internal_colors;
|
||||||
use galaxyui::elements::{
|
use galaxyui::elements::{
|
||||||
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
|
Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||||
MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, HighlightedHyperlink,
|
||||||
Shrinkable, Text,
|
Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||||
|
ParentOffsetBounds, Radius, ScrollbarWidth, Shrinkable, Text,
|
||||||
};
|
};
|
||||||
use galaxyui::{AppContext, Element, SingletonEntity, ViewContext};
|
use galaxyui::{AppContext, Element, SingletonEntity, ViewContext};
|
||||||
use lsp::{CompletionItemData, CompletionKind, CompletionResult, CompletionTrigger};
|
use lsp::{CompletionItem, CompletionItemData, CompletionKind, CompletionResult, CompletionTrigger};
|
||||||
|
use markdown_parser::FormattedText;
|
||||||
use pathfinder_geometry::vector::Vector2F;
|
use pathfinder_geometry::vector::Vector2F;
|
||||||
use string_offset::CharOffset;
|
use string_offset::CharOffset;
|
||||||
use vec1::Vec1;
|
use vec1::Vec1;
|
||||||
|
|
||||||
use super::local_code_editor::LocalCodeEditorView;
|
use super::local_code_editor::{LocalCodeEditorAction, LocalCodeEditorView};
|
||||||
|
|
||||||
pub const COMPLETION_DEBOUNCE_PERIOD: Duration = Duration::from_millis(50);
|
pub const COMPLETION_DEBOUNCE_PERIOD: Duration = Duration::from_millis(50);
|
||||||
const COMPLETION_MENU_MAX_HEIGHT: f32 = 220.;
|
const COMPLETION_MENU_MAX_HEIGHT: f32 = 220.;
|
||||||
const COMPLETION_MENU_WIDTH: f32 = 340.;
|
const COMPLETION_MENU_WIDTH: f32 = 340.;
|
||||||
|
const COMPLETION_DOCS_PANEL_WIDTH: f32 = 320.;
|
||||||
|
const COMPLETION_DOCS_PANEL_MAX_HEIGHT: f32 = 220.;
|
||||||
const MAX_VISIBLE_ITEMS: usize = 10;
|
const MAX_VISIBLE_ITEMS: usize = 10;
|
||||||
|
|
||||||
|
/// Resolved documentation for a completion item.
|
||||||
|
pub(super) struct ResolvedDocumentation {
|
||||||
|
/// The index into `items` that this documentation belongs to.
|
||||||
|
pub item_index: usize,
|
||||||
|
/// The documentation text (may be markdown).
|
||||||
|
pub text: FormattedText,
|
||||||
|
/// Scroll state for the docs panel.
|
||||||
|
pub scroll_state: ClippedScrollStateHandle,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) enum CompletionState {
|
pub(super) enum CompletionState {
|
||||||
Idle,
|
Idle,
|
||||||
Requesting { abort_handle: AbortHandle },
|
Requesting { abort_handle: AbortHandle },
|
||||||
@@ -30,6 +44,14 @@ pub(super) enum CompletionState {
|
|||||||
filtered_indices: Vec<usize>,
|
filtered_indices: Vec<usize>,
|
||||||
selected_index: usize,
|
selected_index: usize,
|
||||||
trigger_offset: CharOffset,
|
trigger_offset: CharOffset,
|
||||||
|
/// Mouse state handles for each visible item (for hover detection).
|
||||||
|
item_mouse_states: Vec<MouseStateHandle>,
|
||||||
|
/// Resolved documentation for the currently hovered/selected item.
|
||||||
|
resolved_docs: Option<ResolvedDocumentation>,
|
||||||
|
/// Abort handle for an in-flight completion resolve request.
|
||||||
|
resolve_abort_handle: Option<AbortHandle>,
|
||||||
|
/// Scroll state for the completion menu items list.
|
||||||
|
menu_scroll_state: ClippedScrollStateHandle,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,24 +73,64 @@ impl CompletionState {
|
|||||||
if let Self::Requesting { abort_handle, .. } = self {
|
if let Self::Requesting { abort_handle, .. } = self {
|
||||||
abort_handle.abort();
|
abort_handle.abort();
|
||||||
}
|
}
|
||||||
|
if let Self::Showing {
|
||||||
|
resolve_abort_handle,
|
||||||
|
..
|
||||||
|
} = self
|
||||||
|
{
|
||||||
|
if let Some(handle) = resolve_abort_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
*self = Self::Idle;
|
*self = Self::Idle;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_selection(&mut self, delta: i32) {
|
pub fn move_selection(&mut self, delta: i32) -> bool {
|
||||||
if let Self::Showing {
|
if let Self::Showing {
|
||||||
filtered_indices,
|
filtered_indices,
|
||||||
selected_index,
|
selected_index,
|
||||||
|
resolved_docs,
|
||||||
|
resolve_abort_handle,
|
||||||
..
|
..
|
||||||
} = self
|
} = self
|
||||||
{
|
{
|
||||||
if filtered_indices.is_empty() {
|
if filtered_indices.is_empty() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let len = filtered_indices.len() as i32;
|
let len = filtered_indices.len() as i32;
|
||||||
let new_idx = (*selected_index as i32 + delta).rem_euclid(len);
|
let new_idx = (*selected_index as i32 + delta).rem_euclid(len);
|
||||||
*selected_index = new_idx as usize;
|
if new_idx as usize != *selected_index {
|
||||||
|
*selected_index = new_idx as usize;
|
||||||
|
*resolved_docs = None;
|
||||||
|
if let Some(handle) = resolve_abort_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_selected_index(&mut self, index: usize) -> bool {
|
||||||
|
if let Self::Showing {
|
||||||
|
filtered_indices,
|
||||||
|
selected_index,
|
||||||
|
resolved_docs,
|
||||||
|
resolve_abort_handle,
|
||||||
|
..
|
||||||
|
} = self
|
||||||
|
{
|
||||||
|
if index < filtered_indices.len() && index != *selected_index {
|
||||||
|
*selected_index = index;
|
||||||
|
*resolved_docs = None;
|
||||||
|
if let Some(handle) = resolve_abort_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn filter(&mut self, query: &str) {
|
pub fn filter(&mut self, query: &str) {
|
||||||
@@ -76,6 +138,9 @@ impl CompletionState {
|
|||||||
items,
|
items,
|
||||||
filtered_indices,
|
filtered_indices,
|
||||||
selected_index,
|
selected_index,
|
||||||
|
resolved_docs,
|
||||||
|
resolve_abort_handle,
|
||||||
|
item_mouse_states,
|
||||||
..
|
..
|
||||||
} = self
|
} = self
|
||||||
{
|
{
|
||||||
@@ -96,6 +161,14 @@ impl CompletionState {
|
|||||||
if *selected_index >= filtered_indices.len() {
|
if *selected_index >= filtered_indices.len() {
|
||||||
*selected_index = 0;
|
*selected_index = 0;
|
||||||
}
|
}
|
||||||
|
*resolved_docs = None;
|
||||||
|
if let Some(handle) = resolve_abort_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
let visible_count = filtered_indices.len().min(MAX_VISIBLE_ITEMS);
|
||||||
|
while item_mouse_states.len() < visible_count {
|
||||||
|
item_mouse_states.push(MouseStateHandle::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +208,11 @@ impl LocalCodeEditorView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.suppress_next_completion {
|
||||||
|
self.suppress_next_completion = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx);
|
let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx);
|
||||||
if cursor_offset == CharOffset::from(0) {
|
if cursor_offset == CharOffset::from(0) {
|
||||||
return;
|
return;
|
||||||
@@ -233,11 +311,29 @@ impl LocalCodeEditorView {
|
|||||||
if let CompletionState::Showing { trigger_offset, .. } = &self.completion_state {
|
if let CompletionState::Showing { trigger_offset, .. } = &self.completion_state {
|
||||||
let query = self.get_completion_filter_query(*trigger_offset, ctx);
|
let query = self.get_completion_filter_query(*trigger_offset, ctx);
|
||||||
self.completion_state.filter(&query);
|
self.completion_state.filter(&query);
|
||||||
|
self.resolve_selected_completion_docs(ctx);
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.request_completion(offset, CompletionTrigger::Invoked, ctx);
|
let word_start = self.find_word_start(offset, ctx);
|
||||||
|
self.request_completion(word_start, CompletionTrigger::Invoked, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the start of the current identifier word by walking backwards from `offset`.
|
||||||
|
fn find_word_start(&self, offset: CharOffset, ctx: &ViewContext<Self>) -> CharOffset {
|
||||||
|
let editor = self.editor().as_ref(ctx);
|
||||||
|
let mut pos = offset;
|
||||||
|
while pos > CharOffset::from(0) {
|
||||||
|
let prev = pos - CharOffset::from(1);
|
||||||
|
match editor.char_at(prev, ctx) {
|
||||||
|
Some(c) if c.is_alphanumeric() || c == '_' => {
|
||||||
|
pos = prev;
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_completion_response(
|
fn handle_completion_response(
|
||||||
@@ -257,17 +353,26 @@ impl LocalCodeEditorView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let filtered_indices: Vec<usize> = (0..completion_result.items.len()).collect();
|
let filtered_indices: Vec<usize> = (0..completion_result.items.len()).collect();
|
||||||
|
let visible_count = filtered_indices.len().min(MAX_VISIBLE_ITEMS);
|
||||||
|
let item_mouse_states = (0..visible_count)
|
||||||
|
.map(|_| MouseStateHandle::default())
|
||||||
|
.collect();
|
||||||
|
|
||||||
self.completion_state = CompletionState::Showing {
|
self.completion_state = CompletionState::Showing {
|
||||||
items: completion_result.items,
|
items: completion_result.items,
|
||||||
filtered_indices,
|
filtered_indices,
|
||||||
selected_index: 0,
|
selected_index: 0,
|
||||||
trigger_offset,
|
trigger_offset,
|
||||||
|
item_mouse_states,
|
||||||
|
resolved_docs: None,
|
||||||
|
resolve_abort_handle: None,
|
||||||
|
menu_scroll_state: ClippedScrollStateHandle::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let query = self.get_completion_filter_query(trigger_offset, ctx);
|
let query = self.get_completion_filter_query(trigger_offset, ctx);
|
||||||
self.completion_state.filter(&query);
|
self.completion_state.filter(&query);
|
||||||
|
|
||||||
|
self.resolve_selected_completion_docs(ctx);
|
||||||
self.sync_completion_intercept(ctx);
|
self.sync_completion_intercept(ctx);
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
@@ -310,15 +415,16 @@ impl LocalCodeEditorView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.completion_state = CompletionState::Idle;
|
self.completion_state = CompletionState::Idle;
|
||||||
|
self.suppress_next_completion = true;
|
||||||
self.sync_completion_intercept(ctx);
|
self.sync_completion_intercept(ctx);
|
||||||
|
|
||||||
self.editor.update(ctx, |editor, ctx| {
|
self.editor.update(ctx, |editor, ctx| {
|
||||||
|
let cursor = editor.cursor_head_offset(ctx);
|
||||||
let edit_range = if let Some(range) = text_edit_range {
|
let edit_range = if let Some(range) = text_edit_range {
|
||||||
let start = editor.lsp_location_to_offset(&range.start, ctx);
|
let start = editor.lsp_location_to_offset(&range.start, ctx);
|
||||||
let end = editor.lsp_location_to_offset(&range.end, ctx);
|
// Extend the end to cover any text the user typed after the completion was requested
|
||||||
start..end
|
start..cursor
|
||||||
} else {
|
} else {
|
||||||
let cursor = editor.cursor_head_offset(ctx);
|
|
||||||
trigger_offset..cursor
|
trigger_offset..cursor
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,11 +437,141 @@ impl LocalCodeEditorView {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve documentation for the currently selected completion item.
|
||||||
|
pub(super) fn resolve_selected_completion_docs(&mut self, ctx: &mut ViewContext<Self>) {
|
||||||
|
let raw_item = match &self.completion_state {
|
||||||
|
CompletionState::Showing {
|
||||||
|
items,
|
||||||
|
filtered_indices,
|
||||||
|
selected_index,
|
||||||
|
resolved_docs,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let Some(&item_idx) = filtered_indices.get(*selected_index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Already resolved for this item
|
||||||
|
if resolved_docs
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|d| d.item_index == item_idx)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items[item_idx].raw_item.clone()
|
||||||
|
}
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(lsp_server) = &self.lsp_server else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let future = match lsp_server.as_ref(ctx).completion_resolve(raw_item) {
|
||||||
|
Ok(future) => future,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let abort_handle = ctx
|
||||||
|
.spawn(future, |me, result, ctx| {
|
||||||
|
me.handle_completion_resolve_response(result, ctx);
|
||||||
|
})
|
||||||
|
.abort_handle();
|
||||||
|
|
||||||
|
if let CompletionState::Showing {
|
||||||
|
resolve_abort_handle,
|
||||||
|
..
|
||||||
|
} = &mut self.completion_state
|
||||||
|
{
|
||||||
|
if let Some(old) = resolve_abort_handle.take() {
|
||||||
|
old.abort();
|
||||||
|
}
|
||||||
|
*resolve_abort_handle = Some(abort_handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_completion_resolve_response(
|
||||||
|
&mut self,
|
||||||
|
result: anyhow::Result<CompletionItem>,
|
||||||
|
ctx: &mut ViewContext<Self>,
|
||||||
|
) {
|
||||||
|
let resolved_item = match result {
|
||||||
|
Ok(item) => item,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc_string = match resolved_item.documentation {
|
||||||
|
Some(lsp_types::Documentation::String(s)) => s,
|
||||||
|
Some(lsp_types::Documentation::MarkupContent(m)) => m.value,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if doc_string.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatted = match markdown_parser::parse_markdown(&doc_string) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(_) => {
|
||||||
|
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
|
||||||
|
FormattedText::new([FormattedTextLine::Line(vec![
|
||||||
|
FormattedTextFragment::plain_text(doc_string),
|
||||||
|
])])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let CompletionState::Showing {
|
||||||
|
filtered_indices,
|
||||||
|
selected_index,
|
||||||
|
resolved_docs,
|
||||||
|
resolve_abort_handle,
|
||||||
|
..
|
||||||
|
} = &mut self.completion_state
|
||||||
|
{
|
||||||
|
*resolve_abort_handle = None;
|
||||||
|
if let Some(&item_idx) = filtered_indices.get(*selected_index) {
|
||||||
|
*resolved_docs = Some(ResolvedDocumentation {
|
||||||
|
item_index: item_idx,
|
||||||
|
text: formatted,
|
||||||
|
scroll_state: ClippedScrollStateHandle::default(),
|
||||||
|
});
|
||||||
|
ctx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle hover over a completion item (triggered via action from Hoverable).
|
||||||
|
pub(super) fn handle_completion_hover_item(
|
||||||
|
&mut self,
|
||||||
|
display_index: usize,
|
||||||
|
ctx: &mut ViewContext<Self>,
|
||||||
|
) {
|
||||||
|
if self.completion_state.set_selected_index(display_index) {
|
||||||
|
self.resolve_selected_completion_docs(ctx);
|
||||||
|
ctx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually trigger completion (Ctrl+Alt+Space).
|
||||||
|
pub(super) fn trigger_completion_manually(&mut self, ctx: &mut ViewContext<Self>) {
|
||||||
|
if !Self::is_completion_enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.lsp_server.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx);
|
||||||
|
let word_start = self.find_word_start(cursor_offset, ctx);
|
||||||
|
self.request_completion(word_start, CompletionTrigger::Invoked, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn render_completion_menu(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
pub(super) fn render_completion_menu(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||||
let CompletionState::Showing {
|
let CompletionState::Showing {
|
||||||
items,
|
items,
|
||||||
filtered_indices,
|
filtered_indices,
|
||||||
selected_index,
|
selected_index,
|
||||||
|
item_mouse_states,
|
||||||
|
resolved_docs,
|
||||||
|
menu_scroll_state,
|
||||||
..
|
..
|
||||||
} = &self.completion_state
|
} = &self.completion_state
|
||||||
else {
|
else {
|
||||||
@@ -357,36 +593,118 @@ impl LocalCodeEditorView {
|
|||||||
{
|
{
|
||||||
let item = &items[item_idx];
|
let item = &items[item_idx];
|
||||||
let is_selected = display_idx == *selected_index;
|
let is_selected = display_idx == *selected_index;
|
||||||
content_column.add_child(render_completion_item(item, is_selected, appearance));
|
let mouse_state = item_mouse_states
|
||||||
|
.get(display_idx)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let item_element = render_completion_item(item, is_selected, appearance);
|
||||||
|
let hoverable_item = Hoverable::new(mouse_state, move |_| item_element)
|
||||||
|
.on_hover(move |is_hovered, ctx, _, _| {
|
||||||
|
if is_hovered {
|
||||||
|
ctx.dispatch_typed_action(
|
||||||
|
LocalCodeEditorAction::CompletionHoverItem(display_idx),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_click(move |ctx, _, _| {
|
||||||
|
ctx.dispatch_typed_action(LocalCodeEditorAction::CompletionConfirmItem);
|
||||||
|
})
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
content_column.add_child(hoverable_item);
|
||||||
}
|
}
|
||||||
|
|
||||||
let constrained_content = ConstrainedBox::new(content_column.finish())
|
let scrollable_content = ClippedScrollable::vertical(
|
||||||
|
menu_scroll_state.clone(),
|
||||||
|
content_column.finish(),
|
||||||
|
ScrollbarWidth::Custom(4.),
|
||||||
|
theme.disabled_ui_text_color().into(),
|
||||||
|
theme.active_ui_text_color().into(),
|
||||||
|
galaxyui::elements::Fill::None,
|
||||||
|
)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
let constrained_content = ConstrainedBox::new(scrollable_content)
|
||||||
.with_width(COMPLETION_MENU_WIDTH)
|
.with_width(COMPLETION_MENU_WIDTH)
|
||||||
.with_max_height(COMPLETION_MENU_MAX_HEIGHT)
|
.with_max_height(COMPLETION_MENU_MAX_HEIGHT)
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
let menu = Container::new(constrained_content)
|
let menu_box = Container::new(constrained_content)
|
||||||
.with_background(theme.background())
|
.with_background(theme.background())
|
||||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||||
.with_border(Border::all(1.).with_border_fill(internal_colors::neutral_4(theme)))
|
.with_border(Border::all(1.).with_border_fill(internal_colors::neutral_4(theme)))
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
Some(menu)
|
// Build docs panel if we have resolved documentation
|
||||||
|
let docs_panel = resolved_docs.as_ref().map(|docs| {
|
||||||
|
let text_element = FormattedTextElement::new(
|
||||||
|
docs.text.clone(),
|
||||||
|
appearance.monospace_font_size(),
|
||||||
|
appearance.ui_font_family(),
|
||||||
|
appearance.monospace_font_family(),
|
||||||
|
theme.active_ui_text_color().into(),
|
||||||
|
HighlightedHyperlink::default(),
|
||||||
|
)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
let scrollable_content = ClippedScrollable::vertical(
|
||||||
|
docs.scroll_state.clone(),
|
||||||
|
text_element,
|
||||||
|
ScrollbarWidth::Auto,
|
||||||
|
theme.disabled_ui_text_color().into(),
|
||||||
|
theme.active_ui_text_color().into(),
|
||||||
|
galaxyui::elements::Fill::None,
|
||||||
|
)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
let constrained_docs = ConstrainedBox::new(scrollable_content)
|
||||||
|
.with_width(COMPLETION_DOCS_PANEL_WIDTH)
|
||||||
|
.with_max_height(COMPLETION_DOCS_PANEL_MAX_HEIGHT)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Container::new(constrained_docs)
|
||||||
|
.with_horizontal_padding(8.)
|
||||||
|
.with_vertical_padding(6.)
|
||||||
|
.with_background(theme.background())
|
||||||
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||||
|
.with_border(Border::all(1.).with_border_fill(internal_colors::neutral_4(theme)))
|
||||||
|
.finish()
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we have docs, lay them out side-by-side in a row
|
||||||
|
if let Some(docs_element) = docs_panel {
|
||||||
|
let row = Flex::row()
|
||||||
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||||
|
.with_child(menu_box)
|
||||||
|
.with_child(
|
||||||
|
Container::new(docs_element)
|
||||||
|
.with_padding_left(2.)
|
||||||
|
.finish(),
|
||||||
|
)
|
||||||
|
.finish();
|
||||||
|
Some(ConstrainedBox::new(row)
|
||||||
|
.with_max_height(COMPLETION_MENU_MAX_HEIGHT)
|
||||||
|
.finish())
|
||||||
|
} else {
|
||||||
|
Some(menu_box)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn completion_menu_positioning(
|
pub(super) fn completion_menu_positioning(
|
||||||
&self,
|
&self,
|
||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Option<OffsetPositioning> {
|
) -> Option<OffsetPositioning> {
|
||||||
let trigger_offset = match &self.completion_state {
|
if !self.completion_state.is_showing() {
|
||||||
CompletionState::Showing { trigger_offset, .. } => *trigger_offset,
|
return None;
|
||||||
_ => return None,
|
}
|
||||||
};
|
|
||||||
|
let cursor_offset = self.editor().as_ref(app).cursor_head_offset(app);
|
||||||
|
|
||||||
let bounds = self
|
let bounds = self
|
||||||
.editor()
|
.editor()
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.character_bounds_in_viewport(trigger_offset, app)?;
|
.character_bounds_in_viewport(cursor_offset, app)?;
|
||||||
|
|
||||||
Some(OffsetPositioning::offset_from_parent(
|
Some(OffsetPositioning::offset_from_parent(
|
||||||
Vector2F::new(bounds.origin_x(), bounds.max_y()),
|
Vector2F::new(bounds.origin_x(), bounds.max_y()),
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ pub fn init(app: &mut AppContext) {
|
|||||||
LocalCodeEditorAction::StartRename,
|
LocalCodeEditorAction::StartRename,
|
||||||
id!("LocalCodeEditorView"),
|
id!("LocalCodeEditorView"),
|
||||||
),
|
),
|
||||||
|
FixedBinding::new(
|
||||||
|
"ctrl-alt-space",
|
||||||
|
LocalCodeEditorAction::TriggerCompletion,
|
||||||
|
id!("LocalCodeEditorView"),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +222,12 @@ pub enum LocalCodeEditorAction {
|
|||||||
OpenCodeActions,
|
OpenCodeActions,
|
||||||
/// Start LSP rename at cursor (F2).
|
/// Start LSP rename at cursor (F2).
|
||||||
StartRename,
|
StartRename,
|
||||||
|
/// Manually trigger completion (Ctrl+Alt+Space).
|
||||||
|
TriggerCompletion,
|
||||||
|
/// Hover over a completion item by display index.
|
||||||
|
CompletionHoverItem(usize),
|
||||||
|
/// Confirm completion via mouse click.
|
||||||
|
CompletionConfirmItem,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -327,6 +338,8 @@ pub struct LocalCodeEditorView {
|
|||||||
pub(super) completion_state: CompletionState,
|
pub(super) completion_state: CompletionState,
|
||||||
/// Channel for debouncing completion requests triggered by typing.
|
/// Channel for debouncing completion requests triggered by typing.
|
||||||
pub(super) completion_debounce_tx: async_channel::Sender<CharOffset>,
|
pub(super) completion_debounce_tx: async_channel::Sender<CharOffset>,
|
||||||
|
/// Suppresses the next content-changed completion trigger (after confirming a completion).
|
||||||
|
pub(super) suppress_next_completion: bool,
|
||||||
/// State for LSP code actions (quick fixes, refactorings).
|
/// State for LSP code actions (quick fixes, refactorings).
|
||||||
pub(super) code_actions_state: CodeActionsState,
|
pub(super) code_actions_state: CodeActionsState,
|
||||||
/// Channel for debouncing code actions requests on cursor/selection change.
|
/// Channel for debouncing code actions requests on cursor/selection change.
|
||||||
@@ -486,11 +499,15 @@ impl LocalCodeEditorView {
|
|||||||
ctx.emit(LocalCodeEditorEvent::DelayedRenderingFlushed);
|
ctx.emit(LocalCodeEditorEvent::DelayedRenderingFlushed);
|
||||||
}
|
}
|
||||||
CodeEditorEvent::CompletionNavigateUp => {
|
CodeEditorEvent::CompletionNavigateUp => {
|
||||||
me.completion_state.move_selection(-1);
|
if me.completion_state.move_selection(-1) {
|
||||||
|
me.resolve_selected_completion_docs(ctx);
|
||||||
|
}
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
CodeEditorEvent::CompletionNavigateDown => {
|
CodeEditorEvent::CompletionNavigateDown => {
|
||||||
me.completion_state.move_selection(1);
|
if me.completion_state.move_selection(1) {
|
||||||
|
me.resolve_selected_completion_docs(ctx);
|
||||||
|
}
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
CodeEditorEvent::CompletionConfirm => {
|
CodeEditorEvent::CompletionConfirm => {
|
||||||
@@ -562,6 +579,7 @@ impl LocalCodeEditorView {
|
|||||||
find_references_view: None,
|
find_references_view: None,
|
||||||
completion_state: CompletionState::default(),
|
completion_state: CompletionState::default(),
|
||||||
completion_debounce_tx,
|
completion_debounce_tx,
|
||||||
|
suppress_next_completion: false,
|
||||||
code_actions_state: CodeActionsState::default(),
|
code_actions_state: CodeActionsState::default(),
|
||||||
code_actions_debounce_tx,
|
code_actions_debounce_tx,
|
||||||
signature_help_state: SignatureHelpState::default(),
|
signature_help_state: SignatureHelpState::default(),
|
||||||
@@ -2376,6 +2394,15 @@ impl TypedActionView for LocalCodeEditorView {
|
|||||||
LocalCodeEditorAction::StartRename => {
|
LocalCodeEditorAction::StartRename => {
|
||||||
self.start_rename(ctx);
|
self.start_rename(ctx);
|
||||||
}
|
}
|
||||||
|
LocalCodeEditorAction::TriggerCompletion => {
|
||||||
|
self.trigger_completion_manually(ctx);
|
||||||
|
}
|
||||||
|
LocalCodeEditorAction::CompletionHoverItem(display_index) => {
|
||||||
|
self.handle_completion_hover_item(*display_index, ctx);
|
||||||
|
}
|
||||||
|
LocalCodeEditorAction::CompletionConfirmItem => {
|
||||||
|
self.confirm_completion(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -918,6 +918,10 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[
|
|||||||
FeatureFlag::LocalDockerSandbox,
|
FeatureFlag::LocalDockerSandbox,
|
||||||
FeatureFlag::VerticalTabsSummaryMode,
|
FeatureFlag::VerticalTabsSummaryMode,
|
||||||
FeatureFlag::CloudModeSetupV2,
|
FeatureFlag::CloudModeSetupV2,
|
||||||
|
FeatureFlag::LspCompletion,
|
||||||
|
FeatureFlag::LspCodeActions,
|
||||||
|
FeatureFlag::LspRename,
|
||||||
|
FeatureFlag::LspSignatureHelp,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Features enabled for feature preview build users (e.g.: Friends of Warp).
|
/// Features enabled for feature preview build users (e.g.: Friends of Warp).
|
||||||
|
|||||||
Reference in New Issue
Block a user