Files
oak-gpui/crates/editor/src/signature_help.rs
T
Finn Evers ded6467604 Refactor the scrollbar component (#36105)
Closes https://github.com/zed-industries/zed/issues/37621
Improves https://github.com/zed-industries/zed/issues/24623

Adding scrollbars withing Zed's UI currently is rather cumbersome, as it
requires the copying of a lot of code in order for these to work. Wiring
up settings for scrollbar visibilty always has to be done at the call
site and the state has to be saved and maintained by the caller as well.
Similarly, reserving space has to also be handled by the caller.

This PR changes the way scrollbars work in Zed fundamentally by making
use of the new `use_keyed_state` APIs: Instead of saving the state at
the call site, the window now keeps track of the state corresponding to
scrollbars. This enables us to add scrollbars with e.g. one simple call
on divs:
```rust
div()
    .vertical_scrollbar(window, cx)
```
will add a scrollbar to the corresponding container. There are some more
improvements regarding tracking of scrollbar visibility settings (which
is now handled by a trait for each setting that supports this) as well
as reserving space.
Additionally, all needed stuff for layouting, catching events and
reserving space is also now managed by the scrollbar component instead.
This drastically reduces the amount of event listeners and makes
layouting of two scrollbars easier.

Furthermore, this paves the way for more improvements to scrollbars,
such as graceful auto-hide. Only downsight here is that we lose some
customizability in a few areas. However, once this lands, we gain the
ability to quickly follow these up without breaking stuff elsewhere.

This also already fixes a few bugs:
- Scrollbars no longer flicker on first render. 
- Auto-hide now properly works for all scrollbars.
- If the content size changes, the scrollbar is updated on the same
frame. Both of these happened because we were computing the scrollbar
sizes too early, causing us to use the sizes from the previous frame or
unitialized sizes.
- The project panel no longer jumps if scrolled all the way to the
bottom and the scrollbar actually auto-hides.

Still TODO:
- [x] Fix scrolling in the debugger memory view
- [x] Clean up some more in the scrollbar component and reduce clones
there
- [x] Ensure we don't over-notify the entity the scrollbar is rendered
within
- [x] Make sure auto-hide properly works for all cases
- [x] Check whether we want to implement the scrollbar trait for
`UniformList`s as well
    - ~~ [ ] Use for uniformlist where possible~~ Postponed
- [x] Improve layout for cases where we render both scrollbars.

Release Notes:

- N/A
2025-09-13 00:43:16 +02:00

463 lines
18 KiB
Rust

use crate::actions::ShowSignatureHelp;
use crate::hover_popover::open_markdown_url;
use crate::{Editor, EditorSettings, ToggleAutoSignatureHelp, hover_markdown_style};
use gpui::{
App, Context, Entity, HighlightStyle, MouseButton, ScrollHandle, Size, StyledText, Task,
TextStyle, Window, combine_highlights,
};
use language::BufferSnapshot;
use markdown::{Markdown, MarkdownElement};
use multi_buffer::{Anchor, ToOffset};
use settings::Settings;
use std::ops::Range;
use text::Rope;
use theme::ThemeSettings;
use ui::{
ActiveTheme, AnyElement, ButtonCommon, ButtonStyle, Clickable, FluentBuilder, IconButton,
IconButtonShape, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon,
LabelSize, ParentElement, Pixels, SharedString, StatefulInteractiveElement, Styled, StyledExt,
WithScrollbar, div, relative,
};
// Language-specific settings may define quotes as "brackets", so filter them out separately.
const QUOTE_PAIRS: [(&str, &str); 3] = [("'", "'"), ("\"", "\""), ("`", "`")];
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SignatureHelpHiddenBy {
AutoClose,
Escape,
Selection,
}
impl Editor {
pub fn toggle_auto_signature_help_menu(
&mut self,
_: &ToggleAutoSignatureHelp,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.auto_signature_help = self
.auto_signature_help
.map(|auto_signature_help| !auto_signature_help)
.or_else(|| Some(!EditorSettings::get_global(cx).auto_signature_help));
match self.auto_signature_help {
Some(true) => {
self.show_signature_help(&ShowSignatureHelp, window, cx);
}
Some(false) => {
self.hide_signature_help(cx, SignatureHelpHiddenBy::AutoClose);
}
None => {}
}
}
pub(super) fn hide_signature_help(
&mut self,
cx: &mut Context<Self>,
signature_help_hidden_by: SignatureHelpHiddenBy,
) -> bool {
if self.signature_help_state.is_shown() {
self.signature_help_state.task = None;
self.signature_help_state.hide(signature_help_hidden_by);
cx.notify();
true
} else {
false
}
}
pub fn auto_signature_help_enabled(&self, cx: &App) -> bool {
if let Some(auto_signature_help) = self.auto_signature_help {
auto_signature_help
} else {
EditorSettings::get_global(cx).auto_signature_help
}
}
pub(super) fn should_open_signature_help_automatically(
&mut self,
old_cursor_position: &Anchor,
cx: &mut Context<Self>,
) -> bool {
if !(self.signature_help_state.is_shown() || self.auto_signature_help_enabled(cx)) {
return false;
}
let newest_selection = self.selections.newest::<usize>(cx);
let head = newest_selection.head();
if !newest_selection.is_empty() && head != newest_selection.tail() {
self.signature_help_state
.hide(SignatureHelpHiddenBy::Selection);
return false;
}
let buffer_snapshot = self.buffer().read(cx).snapshot(cx);
let bracket_range = |position: usize| match (position, position + 1) {
(0, b) if b <= buffer_snapshot.len() => 0..b,
(0, b) => 0..b - 1,
(a, b) if b <= buffer_snapshot.len() => a - 1..b,
(a, b) => a - 1..b - 1,
};
let not_quote_like_brackets =
|buffer: &BufferSnapshot, start: Range<usize>, end: Range<usize>| {
let text_start = buffer.text_for_range(start).collect::<String>();
let text_end = buffer.text_for_range(end).collect::<String>();
QUOTE_PAIRS
.into_iter()
.all(|(start, end)| text_start != start && text_end != end)
};
let previous_position = old_cursor_position.to_offset(&buffer_snapshot);
let previous_brackets_range = bracket_range(previous_position);
let previous_brackets_surround = buffer_snapshot
.innermost_enclosing_bracket_ranges(
previous_brackets_range,
Some(&not_quote_like_brackets),
)
.filter(|(start_bracket_range, end_bracket_range)| {
start_bracket_range.start != previous_position
&& end_bracket_range.end != previous_position
});
let current_brackets_range = bracket_range(head);
let current_brackets_surround = buffer_snapshot
.innermost_enclosing_bracket_ranges(
current_brackets_range,
Some(&not_quote_like_brackets),
)
.filter(|(start_bracket_range, end_bracket_range)| {
start_bracket_range.start != head && end_bracket_range.end != head
});
match (previous_brackets_surround, current_brackets_surround) {
(None, None) => {
self.signature_help_state
.hide(SignatureHelpHiddenBy::AutoClose);
false
}
(Some(_), None) => {
self.signature_help_state
.hide(SignatureHelpHiddenBy::AutoClose);
false
}
(None, Some(_)) => true,
(Some(previous), Some(current)) => {
let condition = self.signature_help_state.hidden_by_selection()
|| previous != current
|| (previous == current && self.signature_help_state.is_shown());
if !condition {
self.signature_help_state
.hide(SignatureHelpHiddenBy::AutoClose);
}
condition
}
}
}
pub fn show_signature_help(
&mut self,
_: &ShowSignatureHelp,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.pending_rename.is_some() || self.has_visible_completions_menu() {
return;
}
let position = self.selections.newest_anchor().head();
let Some((buffer, buffer_position)) =
self.buffer.read(cx).text_anchor_for_position(position, cx)
else {
return;
};
let Some(lsp_store) = self.project().map(|p| p.read(cx).lsp_store()) else {
return;
};
let task = lsp_store.update(cx, |lsp_store, cx| {
lsp_store.signature_help(&buffer, buffer_position, cx)
});
let language = self.language_at(position, cx);
self.signature_help_state
.set_task(cx.spawn_in(window, async move |editor, cx| {
let signature_help = task.await;
editor
.update(cx, |editor, cx| {
let Some(mut signature_help) =
signature_help.unwrap_or_default().into_iter().next()
else {
editor
.signature_help_state
.hide(SignatureHelpHiddenBy::AutoClose);
return;
};
if let Some(language) = language {
for signature in &mut signature_help.signatures {
let text = Rope::from(signature.label.as_ref());
let highlights = language
.highlight_text(&text, 0..signature.label.len())
.into_iter()
.flat_map(|(range, highlight_id)| {
Some((range, highlight_id.style(cx.theme().syntax())?))
});
signature.highlights =
combine_highlights(signature.highlights.clone(), highlights)
.collect();
}
}
let settings = ThemeSettings::get_global(cx);
let style = TextStyle {
color: cx.theme().colors().text,
font_family: settings.buffer_font.family.clone(),
font_fallbacks: settings.buffer_font.fallbacks.clone(),
font_size: settings.buffer_font_size(cx).into(),
font_weight: settings.buffer_font.weight,
line_height: relative(settings.buffer_line_height.value()),
..TextStyle::default()
};
let scroll_handle = ScrollHandle::new();
let signatures = signature_help
.signatures
.into_iter()
.map(|s| SignatureHelp {
label: s.label,
documentation: s.documentation,
highlights: s.highlights,
active_parameter: s.active_parameter,
parameter_documentation: s
.active_parameter
.and_then(|idx| s.parameters.get(idx))
.and_then(|param| param.documentation.clone()),
})
.collect::<Vec<_>>();
if signatures.is_empty() {
editor
.signature_help_state
.hide(SignatureHelpHiddenBy::AutoClose);
return;
}
let current_signature = signature_help
.active_signature
.min(signatures.len().saturating_sub(1));
let signature_help_popover = SignatureHelpPopover {
style,
signatures,
current_signature,
scroll_handle,
};
editor
.signature_help_state
.set_popover(signature_help_popover);
cx.notify();
})
.ok();
}));
}
}
#[derive(Default, Debug)]
pub struct SignatureHelpState {
task: Option<Task<()>>,
popover: Option<SignatureHelpPopover>,
hidden_by: Option<SignatureHelpHiddenBy>,
}
impl SignatureHelpState {
fn set_task(&mut self, task: Task<()>) {
self.task = Some(task);
self.hidden_by = None;
}
#[cfg(test)]
pub fn popover(&self) -> Option<&SignatureHelpPopover> {
self.popover.as_ref()
}
pub fn popover_mut(&mut self) -> Option<&mut SignatureHelpPopover> {
self.popover.as_mut()
}
fn set_popover(&mut self, popover: SignatureHelpPopover) {
self.popover = Some(popover);
self.hidden_by = None;
}
fn hide(&mut self, hidden_by: SignatureHelpHiddenBy) {
if self.hidden_by.is_none() {
self.popover = None;
self.hidden_by = Some(hidden_by);
}
}
fn hidden_by_selection(&self) -> bool {
self.hidden_by == Some(SignatureHelpHiddenBy::Selection)
}
pub fn is_shown(&self) -> bool {
self.popover.is_some()
}
pub fn has_multiple_signatures(&self) -> bool {
self.popover
.as_ref()
.is_some_and(|popover| popover.signatures.len() > 1)
}
}
#[cfg(test)]
impl SignatureHelpState {
pub fn task(&self) -> Option<&Task<()>> {
self.task.as_ref()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SignatureHelp {
pub(crate) label: SharedString,
documentation: Option<Entity<Markdown>>,
highlights: Vec<(Range<usize>, HighlightStyle)>,
active_parameter: Option<usize>,
parameter_documentation: Option<Entity<Markdown>>,
}
#[derive(Clone, Debug)]
pub struct SignatureHelpPopover {
pub style: TextStyle,
pub signatures: Vec<SignatureHelp>,
pub current_signature: usize,
scroll_handle: ScrollHandle,
}
impl SignatureHelpPopover {
pub fn render(
&mut self,
max_size: Size<Pixels>,
window: &mut Window,
cx: &mut Context<Editor>,
) -> AnyElement {
let Some(signature) = self.signatures.get(self.current_signature) else {
return div().into_any_element();
};
let main_content = div()
.occlude()
.p_2()
.child(
div()
.id("signature_help_container")
.overflow_y_scroll()
.max_w(max_size.width)
.max_h(max_size.height)
.track_scroll(&self.scroll_handle)
.child(
StyledText::new(signature.label.clone()).with_default_highlights(
&self.style,
signature.highlights.iter().cloned(),
),
)
.when_some(
signature.parameter_documentation.clone(),
|this, param_doc| {
this.child(div().h_px().bg(cx.theme().colors().border_variant).my_1())
.child(
MarkdownElement::new(
param_doc,
hover_markdown_style(window, cx),
)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button: false,
border: false,
copy_button_on_hover: false,
})
.on_url_click(open_markdown_url),
)
},
)
.when_some(signature.documentation.clone(), |this, description| {
this.child(div().h_px().bg(cx.theme().colors().border_variant).my_1())
.child(
MarkdownElement::new(description, hover_markdown_style(window, cx))
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button: false,
border: false,
copy_button_on_hover: false,
})
.on_url_click(open_markdown_url),
)
}),
)
.vertical_scrollbar(window, cx);
let controls = if self.signatures.len() > 1 {
let prev_button = IconButton::new("signature_help_prev", IconName::ChevronUp)
.shape(IconButtonShape::Square)
.style(ButtonStyle::Subtle)
.icon_size(IconSize::Small)
.tooltip(move |window, cx| {
ui::Tooltip::for_action(
"Previous Signature",
&crate::SignatureHelpPrevious,
window,
cx,
)
})
.on_click(cx.listener(|editor, _, window, cx| {
editor.signature_help_prev(&crate::SignatureHelpPrevious, window, cx);
}));
let next_button = IconButton::new("signature_help_next", IconName::ChevronDown)
.shape(IconButtonShape::Square)
.style(ButtonStyle::Subtle)
.icon_size(IconSize::Small)
.tooltip(move |window, cx| {
ui::Tooltip::for_action("Next Signature", &crate::SignatureHelpNext, window, cx)
})
.on_click(cx.listener(|editor, _, window, cx| {
editor.signature_help_next(&crate::SignatureHelpNext, window, cx);
}));
let page = Label::new(format!(
"{}/{}",
self.current_signature + 1,
self.signatures.len()
))
.size(LabelSize::Small);
Some(
div()
.flex()
.flex_col()
.items_center()
.gap_0p5()
.px_0p5()
.py_0p5()
.children([
prev_button.into_any_element(),
div().child(page).into_any_element(),
next_button.into_any_element(),
])
.into_any_element(),
)
} else {
None
};
div()
.elevation_2(cx)
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_mouse_move(|_, _, cx| cx.stop_propagation())
.flex()
.flex_row()
.when_some(controls, |this, controls| {
this.children(vec![
div().flex().items_end().child(controls),
div().w_px().bg(cx.theme().colors().border_variant),
])
})
.child(main_content)
.into_any_element()
}
}