diff --git a/app/src/code/local_code_editor.rs b/app/src/code/local_code_editor.rs index d8e6ba67..bf13e60c 100644 --- a/app/src/code/local_code_editor.rs +++ b/app/src/code/local_code_editor.rs @@ -92,6 +92,7 @@ use super::editor::{ }; use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD}; use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD}; +use super::signature_help::SignatureHelpState; use super::find_references_view::{FindReferencesView, FindReferencesViewEvent}; use super::language_server_extension::ProcessedDiagnostic; use super::lsp_telemetry::LspTelemetryEvent; @@ -322,6 +323,8 @@ pub struct LocalCodeEditorView { pub(super) code_actions_state: CodeActionsState, /// Channel for debouncing code actions requests on cursor/selection change. pub(super) code_actions_debounce_tx: async_channel::Sender, + /// State for LSP signature help (parameter hints). + pub(super) signature_help_state: SignatureHelpState, } impl LocalCodeEditorView { @@ -359,6 +362,8 @@ impl LocalCodeEditorView { // Trigger completion on user typing me.on_content_changed_for_completion(ctx); + // Trigger signature help on '(' and ',' + me.on_content_changed_for_signature_help(ctx); } } CodeEditorEvent::VimEscapeInNormalMode => { @@ -549,6 +554,7 @@ impl LocalCodeEditorView { completion_debounce_tx, code_actions_state: CodeActionsState::default(), code_actions_debounce_tx, + signature_help_state: SignatureHelpState::default(), }; if let Some(display_mode) = display_mode { @@ -1973,7 +1979,9 @@ impl LocalCodeEditorView { if had_completion { 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. @@ -2247,6 +2255,14 @@ impl View for LocalCodeEditorView { 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 if let (Some(actions_menu), Some(positioning)) = ( self.render_code_actions_menu(app), diff --git a/app/src/code/mod.rs b/app/src/code/mod.rs index d344fced..0e0bb1cd 100644 --- a/app/src/code/mod.rs +++ b/app/src/code/mod.rs @@ -13,6 +13,8 @@ pub mod completion; #[cfg(not(target_family = "wasm"))] pub mod find_references_view; #[cfg(not(target_family = "wasm"))] +pub mod signature_help; +#[cfg(not(target_family = "wasm"))] pub mod language_server_extension; #[cfg_attr(not(target_family = "wasm"), path = "local_code_editor.rs")] #[cfg_attr(target_family = "wasm", path = "local_code_editor_wasm.rs")] diff --git a/app/src/code/signature_help.rs b/app/src/code/signature_help.rs new file mode 100644 index 00000000..44e7c94e --- /dev/null +++ b/app/src/code/signature_help.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), + 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, + ) { + 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, + ) { + 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> { + 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 { + 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 { + 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() + } +}