Add signature help (Phase 4): parameter hints on function calls
- SignatureHelpState: None → Loading → Showing
- Triggers on '(' and ',' characters, dismisses on ')'
- Renders tooltip above cursor with active parameter highlighted
- Active parameter shown in warning color (bold yellow/orange)
- Includes documentation display when available from LSP
- Dismissed on Escape along with other LSP overlays
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
331fbeeb18
commit
6f7d4757a2
@@ -92,6 +92,7 @@ use super::editor::{
|
|||||||
};
|
};
|
||||||
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
|
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
|
||||||
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
|
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
|
||||||
|
use super::signature_help::SignatureHelpState;
|
||||||
use super::find_references_view::{FindReferencesView, FindReferencesViewEvent};
|
use super::find_references_view::{FindReferencesView, FindReferencesViewEvent};
|
||||||
use super::language_server_extension::ProcessedDiagnostic;
|
use super::language_server_extension::ProcessedDiagnostic;
|
||||||
use super::lsp_telemetry::LspTelemetryEvent;
|
use super::lsp_telemetry::LspTelemetryEvent;
|
||||||
@@ -322,6 +323,8 @@ pub struct LocalCodeEditorView {
|
|||||||
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.
|
||||||
pub(super) code_actions_debounce_tx: async_channel::Sender<CharOffset>,
|
pub(super) code_actions_debounce_tx: async_channel::Sender<CharOffset>,
|
||||||
|
/// State for LSP signature help (parameter hints).
|
||||||
|
pub(super) signature_help_state: SignatureHelpState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalCodeEditorView {
|
impl LocalCodeEditorView {
|
||||||
@@ -359,6 +362,8 @@ impl LocalCodeEditorView {
|
|||||||
|
|
||||||
// Trigger completion on user typing
|
// Trigger completion on user typing
|
||||||
me.on_content_changed_for_completion(ctx);
|
me.on_content_changed_for_completion(ctx);
|
||||||
|
// Trigger signature help on '(' and ','
|
||||||
|
me.on_content_changed_for_signature_help(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CodeEditorEvent::VimEscapeInNormalMode => {
|
CodeEditorEvent::VimEscapeInNormalMode => {
|
||||||
@@ -549,6 +554,7 @@ impl LocalCodeEditorView {
|
|||||||
completion_debounce_tx,
|
completion_debounce_tx,
|
||||||
code_actions_state: CodeActionsState::default(),
|
code_actions_state: CodeActionsState::default(),
|
||||||
code_actions_debounce_tx,
|
code_actions_debounce_tx,
|
||||||
|
signature_help_state: SignatureHelpState::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(display_mode) = display_mode {
|
if let Some(display_mode) = display_mode {
|
||||||
@@ -1973,7 +1979,9 @@ impl LocalCodeEditorView {
|
|||||||
if had_completion {
|
if had_completion {
|
||||||
self.sync_completion_intercept(ctx);
|
self.sync_completion_intercept(ctx);
|
||||||
}
|
}
|
||||||
had_refs || had_hover || had_completion
|
let had_sig_help = self.signature_help_state.clear();
|
||||||
|
let had_code_actions = self.code_actions_state.dismiss();
|
||||||
|
had_refs || had_hover || had_completion || had_sig_help || had_code_actions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform goto definition at the cursor position and navigate directly.
|
/// Perform goto definition at the cursor position and navigate directly.
|
||||||
@@ -2247,6 +2255,14 @@ impl View for LocalCodeEditorView {
|
|||||||
stack.add_positioned_overlay_child(completion_menu, positioning);
|
stack.add_positioned_overlay_child(completion_menu, positioning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render signature help tooltip (above cursor)
|
||||||
|
if let (Some(sig_help), Some(positioning)) = (
|
||||||
|
self.render_signature_help(app),
|
||||||
|
self.signature_help_positioning(app),
|
||||||
|
) {
|
||||||
|
stack.add_positioned_overlay_child(sig_help, positioning);
|
||||||
|
}
|
||||||
|
|
||||||
// Render code actions menu if open
|
// Render code actions menu if open
|
||||||
if let (Some(actions_menu), Some(positioning)) = (
|
if let (Some(actions_menu), Some(positioning)) = (
|
||||||
self.render_code_actions_menu(app),
|
self.render_code_actions_menu(app),
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub mod completion;
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub mod find_references_view;
|
pub mod find_references_view;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub mod signature_help;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub mod language_server_extension;
|
pub mod language_server_extension;
|
||||||
#[cfg_attr(not(target_family = "wasm"), path = "local_code_editor.rs")]
|
#[cfg_attr(not(target_family = "wasm"), path = "local_code_editor.rs")]
|
||||||
#[cfg_attr(target_family = "wasm", path = "local_code_editor_wasm.rs")]
|
#[cfg_attr(target_family = "wasm", path = "local_code_editor_wasm.rs")]
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
use futures::stream::AbortHandle;
|
||||||
|
use galaxy_core::features::FeatureFlag;
|
||||||
|
use galaxy_core::ui::appearance::Appearance;
|
||||||
|
use galaxy_core::ui::theme::color::internal_colors;
|
||||||
|
use galaxyui::elements::{
|
||||||
|
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
|
||||||
|
MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
||||||
|
Shrinkable, Text,
|
||||||
|
};
|
||||||
|
use galaxyui::{AppContext, Element, SingletonEntity, ViewContext};
|
||||||
|
use lsp::{ParameterLabel, SignatureHelpResult, SignatureInfo};
|
||||||
|
use pathfinder_geometry::vector::Vector2F;
|
||||||
|
use string_offset::CharOffset;
|
||||||
|
|
||||||
|
use super::local_code_editor::LocalCodeEditorView;
|
||||||
|
|
||||||
|
const SIGNATURE_HELP_MAX_WIDTH: f32 = 500.;
|
||||||
|
|
||||||
|
/// State for signature help display.
|
||||||
|
pub(super) enum SignatureHelpState {
|
||||||
|
None,
|
||||||
|
Loading(Option<AbortHandle>),
|
||||||
|
Showing {
|
||||||
|
result: SignatureHelpResult,
|
||||||
|
anchor_offset: CharOffset,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SignatureHelpState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignatureHelpState {
|
||||||
|
pub fn clear(&mut self) -> bool {
|
||||||
|
if matches!(self, Self::None) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Self::Loading(Some(handle)) = self {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
*self = Self::None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalCodeEditorView {
|
||||||
|
pub(super) fn is_signature_help_enabled() -> bool {
|
||||||
|
FeatureFlag::LspSignatureHelp.is_enabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a trigger character was typed and request signature help.
|
||||||
|
pub(super) fn on_content_changed_for_signature_help(
|
||||||
|
&mut self,
|
||||||
|
ctx: &mut ViewContext<Self>,
|
||||||
|
) {
|
||||||
|
if !Self::is_signature_help_enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.lsp_server.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx);
|
||||||
|
if cursor_offset == CharOffset::from(0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let char_before = self
|
||||||
|
.editor()
|
||||||
|
.as_ref(ctx)
|
||||||
|
.char_at(cursor_offset - CharOffset::from(1), ctx);
|
||||||
|
|
||||||
|
match char_before {
|
||||||
|
Some('(') | Some(',') => {
|
||||||
|
self.request_signature_help(cursor_offset, ctx);
|
||||||
|
}
|
||||||
|
Some(')') => {
|
||||||
|
if self.signature_help_state.clear() {
|
||||||
|
ctx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_signature_help(
|
||||||
|
&mut self,
|
||||||
|
trigger_offset: CharOffset,
|
||||||
|
ctx: &mut ViewContext<Self>,
|
||||||
|
) {
|
||||||
|
let Some(file_path) = self.file_path() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(lsp_server) = &self.lsp_server else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let lsp_position = self
|
||||||
|
.editor()
|
||||||
|
.as_ref(ctx)
|
||||||
|
.offset_to_lsp_position(trigger_offset, ctx);
|
||||||
|
|
||||||
|
let future = match lsp_server
|
||||||
|
.as_ref(ctx)
|
||||||
|
.signature_help(file_path.to_path_buf(), lsp_position)
|
||||||
|
{
|
||||||
|
Ok(future) => future,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to call lsp.signature_help: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let abort_handle = ctx
|
||||||
|
.spawn(future, move |me, result, ctx| {
|
||||||
|
match result {
|
||||||
|
Ok(Some(sig_help)) if !sig_help.signatures.is_empty() => {
|
||||||
|
me.signature_help_state = SignatureHelpState::Showing {
|
||||||
|
result: sig_help,
|
||||||
|
anchor_offset: trigger_offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
me.signature_help_state = SignatureHelpState::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.notify();
|
||||||
|
})
|
||||||
|
.abort_handle();
|
||||||
|
|
||||||
|
self.signature_help_state = SignatureHelpState::Loading(Some(abort_handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the signature help tooltip.
|
||||||
|
pub(super) fn render_signature_help(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||||
|
let SignatureHelpState::Showing { result, .. } = &self.signature_help_state else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_sig_idx = result.active_signature.unwrap_or(0);
|
||||||
|
let sig = result.signatures.get(active_sig_idx)?;
|
||||||
|
|
||||||
|
let appearance = Appearance::as_ref(app);
|
||||||
|
let theme = appearance.theme();
|
||||||
|
|
||||||
|
let active_param = result.active_parameter.unwrap_or(0);
|
||||||
|
|
||||||
|
// Render the signature label with the active parameter highlighted
|
||||||
|
let label_element = render_signature_label(sig, active_param, appearance);
|
||||||
|
|
||||||
|
// Wrap in container with docs if available
|
||||||
|
let mut content = Flex::column();
|
||||||
|
content.add_child(label_element);
|
||||||
|
|
||||||
|
if let Some(doc) = &sig.documentation {
|
||||||
|
content.add_child(
|
||||||
|
Container::new(
|
||||||
|
Text::new(
|
||||||
|
doc.clone(),
|
||||||
|
appearance.ui_font_family(),
|
||||||
|
appearance.ui_font_size() * 0.9,
|
||||||
|
)
|
||||||
|
.with_color(theme.disabled_ui_text_color().into())
|
||||||
|
.finish(),
|
||||||
|
)
|
||||||
|
.with_padding_top(4.)
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let constrained = ConstrainedBox::new(content.finish())
|
||||||
|
.with_max_width(SIGNATURE_HELP_MAX_WIDTH)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
let tooltip = Container::new(constrained)
|
||||||
|
.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();
|
||||||
|
|
||||||
|
Some(tooltip)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position the signature help tooltip above the cursor.
|
||||||
|
pub(super) fn signature_help_positioning(
|
||||||
|
&self,
|
||||||
|
app: &AppContext,
|
||||||
|
) -> Option<OffsetPositioning> {
|
||||||
|
let anchor_offset = match &self.signature_help_state {
|
||||||
|
SignatureHelpState::Showing { anchor_offset, .. } => *anchor_offset,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bounds = self
|
||||||
|
.editor()
|
||||||
|
.as_ref(app)
|
||||||
|
.character_bounds_in_viewport(anchor_offset, app)?;
|
||||||
|
|
||||||
|
// Position above the cursor
|
||||||
|
Some(OffsetPositioning::offset_from_parent(
|
||||||
|
Vector2F::new(bounds.origin_x(), bounds.origin_y()),
|
||||||
|
ParentOffsetBounds::ParentByPosition,
|
||||||
|
ParentAnchor::TopLeft,
|
||||||
|
ChildAnchor::BottomLeft,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_signature_label(
|
||||||
|
sig: &SignatureInfo,
|
||||||
|
active_param: usize,
|
||||||
|
appearance: &Appearance,
|
||||||
|
) -> Box<dyn Element> {
|
||||||
|
let theme = appearance.theme();
|
||||||
|
|
||||||
|
// If we have parameter info, try to highlight the active parameter in the label
|
||||||
|
if let Some(param) = sig.parameters.get(active_param) {
|
||||||
|
let (before, highlight, after) = match ¶m.label {
|
||||||
|
ParameterLabel::Offsets(start, end) => {
|
||||||
|
let start = *start as usize;
|
||||||
|
let end = *end as usize;
|
||||||
|
if end <= sig.label.len() {
|
||||||
|
(
|
||||||
|
sig.label[..start].to_string(),
|
||||||
|
sig.label[start..end].to_string(),
|
||||||
|
sig.label[end..].to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(sig.label.clone(), String::new(), String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParameterLabel::Simple(name) => {
|
||||||
|
if let Some(idx) = sig.label.find(name.as_str()) {
|
||||||
|
(
|
||||||
|
sig.label[..idx].to_string(),
|
||||||
|
name.clone(),
|
||||||
|
sig.label[idx + name.len()..].to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(sig.label.clone(), String::new(), String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut row = Flex::row()
|
||||||
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||||
|
.with_main_axis_size(MainAxisSize::Min);
|
||||||
|
|
||||||
|
if !before.is_empty() {
|
||||||
|
row.add_child(
|
||||||
|
Text::new(
|
||||||
|
before,
|
||||||
|
appearance.monospace_font_family(),
|
||||||
|
appearance.monospace_font_size(),
|
||||||
|
)
|
||||||
|
.with_color(theme.active_ui_text_color().into())
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !highlight.is_empty() {
|
||||||
|
row.add_child(
|
||||||
|
Text::new(
|
||||||
|
highlight,
|
||||||
|
appearance.monospace_font_family(),
|
||||||
|
appearance.monospace_font_size(),
|
||||||
|
)
|
||||||
|
.with_color(theme.ui_warning_color())
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !after.is_empty() {
|
||||||
|
row.add_child(
|
||||||
|
Text::new(
|
||||||
|
after,
|
||||||
|
appearance.monospace_font_family(),
|
||||||
|
appearance.monospace_font_size(),
|
||||||
|
)
|
||||||
|
.with_color(theme.active_ui_text_color().into())
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.finish()
|
||||||
|
} else {
|
||||||
|
// No parameter info — just show the full label
|
||||||
|
Text::new(
|
||||||
|
sig.label.clone(),
|
||||||
|
appearance.monospace_font_family(),
|
||||||
|
appearance.monospace_font_size(),
|
||||||
|
)
|
||||||
|
.with_color(theme.active_ui_text_color().into())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user