mod head; pub mod highlighted_match_with_paths; pub mod popover_menu; use anyhow::Result; use editor::{ Editor, SelectionEffects, actions::{MoveDown, MoveUp}, scroll::Autoscroll, }; use gpui::{ Action, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Render, ScrollStrategy, Stateful, Task, UniformListScrollHandle, Window, actions, div, list, prelude::*, uniform_list, }; use head::Head; use schemars::JsonSchema; use serde::Deserialize; use std::{ops::Range, sync::Arc, time::Duration}; use ui::{ Color, Divider, Label, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, prelude::*, v_flex, }; use util::ResultExt; use workspace::ModalView; enum ElementContainer { List(ListState), UniformList(UniformListScrollHandle), } pub enum Direction { Up, Down, } actions!(picker, [ConfirmCompletion]); /// ConfirmInput is an alternative editor action which - instead of selecting active picker entry - treats pickers editor input literally, /// performing some kind of action on it. #[derive(Clone, PartialEq, Deserialize, JsonSchema, Default, Action)] #[action(namespace = picker)] #[serde(deny_unknown_fields)] pub struct ConfirmInput { pub secondary: bool, } struct PendingUpdateMatches { delegate_update_matches: Option>, _task: Task>, } pub struct Picker { pub delegate: D, element_container: ElementContainer, head: Head, pending_update_matches: Option, confirm_on_update: Option, width: Option, widest_item: Option, max_height: Option, focus_handle: FocusHandle, /// An external control to display a scrollbar in the `Picker`. show_scrollbar: bool, /// An internal state that controls whether to show the scrollbar based on the user's focus. scrollbar_visibility: bool, scrollbar_state: ScrollbarState, hide_scrollbar_task: Option>, /// Whether the `Picker` is rendered as a self-contained modal. /// /// Set this to `false` when rendering the `Picker` as part of a larger modal. is_modal: bool, } #[derive(Debug, Default, Clone, Copy, PartialEq)] pub enum PickerEditorPosition { #[default] /// Render the editor at the start of the picker. Usually the top Start, /// Render the editor at the end of the picker. Usually the bottom End, } pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; fn match_count(&self) -> usize; fn selected_index(&self) -> usize; fn separators_after_indices(&self) -> Vec { Vec::new() } fn set_selected_index( &mut self, ix: usize, window: &mut Window, cx: &mut Context>, ); fn can_select( &mut self, _ix: usize, _window: &mut Window, _cx: &mut Context>, ) -> bool { true } // Allows binding some optional effect to when the selection changes. fn selected_index_changed( &self, _ix: usize, _window: &mut Window, _cx: &mut Context>, ) -> Option> { None } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc; fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { Some("No matches".into()) } fn update_matches( &mut self, query: String, window: &mut Window, cx: &mut Context>, ) -> Task<()>; // Delegates that support this method (e.g. the CommandPalette) can chose to block on any background // work for up to `duration` to try and get a result synchronously. // This avoids a flash of an empty command-palette on cmd-shift-p, and lets workspace::SendKeystrokes // mostly work when dismissing a palette. fn finalize_update_matches( &mut self, _query: String, _duration: Duration, _window: &mut Window, _cx: &mut Context>, ) -> bool { false } /// Override if you want to have update the query instead of confirming. fn confirm_update_query( &mut self, _window: &mut Window, _cx: &mut Context>, ) -> Option { None } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>); /// Instead of interacting with currently selected entry, treats editor input literally, /// performing some kind of action on it. fn confirm_input( &mut self, _secondary: bool, _window: &mut Window, _: &mut Context>, ) { } fn dismissed(&mut self, window: &mut Window, cx: &mut Context>); fn should_dismiss(&self) -> bool { true } fn confirm_completion( &mut self, _query: String, _window: &mut Window, _: &mut Context>, ) -> Option { None } fn editor_position(&self) -> PickerEditorPosition { PickerEditorPosition::default() } fn render_editor( &self, editor: &Entity, _window: &mut Window, _cx: &mut Context>, ) -> Div { v_flex() .when( self.editor_position() == PickerEditorPosition::End, |this| this.child(Divider::horizontal()), ) .child( h_flex() .overflow_hidden() .flex_none() .h_9() .px_2p5() .child(editor.clone()), ) .when( self.editor_position() == PickerEditorPosition::Start, |this| this.child(Divider::horizontal()), ) } fn render_match( &self, ix: usize, selected: bool, window: &mut Window, cx: &mut Context>, ) -> Option; fn render_header( &self, _window: &mut Window, _: &mut Context>, ) -> Option { None } fn render_footer( &self, _window: &mut Window, _: &mut Context>, ) -> Option { None } } impl Focusable for Picker { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.head { Head::Editor(editor) => editor.focus_handle(cx), Head::Empty(head) => head.focus_handle(cx), } } } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] enum ContainerKind { List, UniformList, } impl Picker { /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height. /// The picker allows the user to perform search items by text. /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`. pub fn uniform_list(delegate: D, window: &mut Window, cx: &mut Context) -> Self { let head = Head::editor( delegate.placeholder_text(window, cx), Self::on_input_editor_event, window, cx, ); Self::new(delegate, ContainerKind::UniformList, head, window, cx) } /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height. /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`. pub fn nonsearchable_uniform_list( delegate: D, window: &mut Window, cx: &mut Context, ) -> Self { let head = Head::empty(Self::on_empty_head_blur, window, cx); Self::new(delegate, ContainerKind::UniformList, head, window, cx) } /// A picker, which displays its matches using `gpui::list`, matches can have different heights. /// The picker allows the user to perform search items by text. /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that. pub fn list(delegate: D, window: &mut Window, cx: &mut Context) -> Self { let head = Head::editor( delegate.placeholder_text(window, cx), Self::on_input_editor_event, window, cx, ); Self::new(delegate, ContainerKind::List, head, window, cx) } fn new( delegate: D, container: ContainerKind, head: Head, window: &mut Window, cx: &mut Context, ) -> Self { let element_container = Self::create_element_container(container, cx); let scrollbar_state = match &element_container { ElementContainer::UniformList(scroll_handle) => { ScrollbarState::new(scroll_handle.clone()) } ElementContainer::List(state) => ScrollbarState::new(state.clone()), }; let focus_handle = cx.focus_handle(); let mut this = Self { delegate, head, element_container, pending_update_matches: None, confirm_on_update: None, width: None, widest_item: None, max_height: Some(rems(18.).into()), focus_handle, show_scrollbar: false, scrollbar_visibility: true, scrollbar_state, is_modal: true, hide_scrollbar_task: None, }; this.update_matches("".to_string(), window, cx); // give the delegate 4ms to render the first set of suggestions. this.delegate .finalize_update_matches("".to_string(), Duration::from_millis(4), window, cx); this } fn create_element_container( container: ContainerKind, cx: &mut Context, ) -> ElementContainer { match container { ContainerKind::UniformList => { ElementContainer::UniformList(UniformListScrollHandle::new()) } ContainerKind::List => { let entity = cx.entity().downgrade(); ElementContainer::List(ListState::new( 0, gpui::ListAlignment::Top, px(1000.), move |ix, window, cx| { entity .upgrade() .map(|entity| { entity.update(cx, |this, cx| { this.render_element(window, cx, ix).into_any_element() }) }) .unwrap_or_else(|| div().into_any_element()) }, )) } } } pub fn width(mut self, width: impl Into) -> Self { self.width = Some(width.into()); self } pub fn widest_item(mut self, ix: Option) -> Self { self.widest_item = ix; self } pub fn max_height(mut self, max_height: Option) -> Self { self.max_height = max_height; self } pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self { self.show_scrollbar = show_scrollbar; self } pub fn modal(mut self, modal: bool) -> Self { self.is_modal = modal; self } pub fn focus(&self, window: &mut Window, cx: &mut App) { self.focus_handle(cx).focus(window); } /// Handles the selecting an index, and passing the change to the delegate. /// If `fallback_direction` is set to `None`, the index will not be selected /// if the element at that index cannot be selected. /// If `fallback_direction` is set to /// `Some(..)`, the next selectable element will be selected in the /// specified direction (Down or Up), cycling through all elements until /// finding one that can be selected or returning if there are no selectable elements. /// If `scroll_to_index` is true, the new selected index will be scrolled into /// view. /// /// If some effect is bound to `selected_index_changed`, it will be executed. pub fn set_selected_index( &mut self, mut ix: usize, fallback_direction: Option, scroll_to_index: bool, window: &mut Window, cx: &mut Context, ) { let match_count = self.delegate.match_count(); if match_count == 0 { return; } if let Some(bias) = fallback_direction { let mut curr_ix = ix; while !self.delegate.can_select(curr_ix, window, cx) { curr_ix = match bias { Direction::Down => { if curr_ix == match_count - 1 { 0 } else { curr_ix + 1 } } Direction::Up => { if curr_ix == 0 { match_count - 1 } else { curr_ix - 1 } } }; // There is no item that can be selected if ix == curr_ix { return; } } ix = curr_ix; } else if !self.delegate.can_select(ix, window, cx) { return; } let previous_index = self.delegate.selected_index(); self.delegate.set_selected_index(ix, window, cx); let current_index = self.delegate.selected_index(); if previous_index != current_index { if let Some(action) = self.delegate.selected_index_changed(ix, window, cx) { action(window, cx); } if scroll_to_index { self.scroll_to_item_index(ix); } } } pub fn select_next( &mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context, ) { let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); let ix = if index == count - 1 { 0 } else { index + 1 }; self.set_selected_index(ix, Some(Direction::Down), true, window, cx); cx.notify(); } } pub fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { self.select_previous(&Default::default(), window, cx); } fn select_previous( &mut self, _: &menu::SelectPrevious, window: &mut Window, cx: &mut Context, ) { let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); let ix = if index == 0 { count - 1 } else { index - 1 }; self.set_selected_index(ix, Some(Direction::Up), true, window, cx); cx.notify(); } } pub fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { self.select_next(&Default::default(), window, cx); } pub fn select_first( &mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context, ) { let count = self.delegate.match_count(); if count > 0 { self.set_selected_index(0, Some(Direction::Down), true, window, cx); cx.notify(); } } fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context) { let count = self.delegate.match_count(); if count > 0 { self.set_selected_index(count - 1, Some(Direction::Up), true, window, cx); cx.notify(); } } pub fn cycle_selection(&mut self, window: &mut Window, cx: &mut Context) { let count = self.delegate.match_count(); let index = self.delegate.selected_index(); let new_index = if index + 1 == count { 0 } else { index + 1 }; self.set_selected_index(new_index, Some(Direction::Down), true, window, cx); cx.notify(); } pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { if self.delegate.should_dismiss() { self.delegate.dismissed(window, cx); cx.emit(DismissEvent); } } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { if self.pending_update_matches.is_some() && !self.delegate.finalize_update_matches( self.query(cx), Duration::from_millis(16), window, cx, ) { self.confirm_on_update = Some(false) } else { self.pending_update_matches.take(); self.do_confirm(false, window, cx); } } fn secondary_confirm( &mut self, _: &menu::SecondaryConfirm, window: &mut Window, cx: &mut Context, ) { if self.pending_update_matches.is_some() && !self.delegate.finalize_update_matches( self.query(cx), Duration::from_millis(16), window, cx, ) { self.confirm_on_update = Some(true) } else { self.do_confirm(true, window, cx); } } fn confirm_input(&mut self, input: &ConfirmInput, window: &mut Window, cx: &mut Context) { self.delegate.confirm_input(input.secondary, window, cx); } fn confirm_completion( &mut self, _: &ConfirmCompletion, window: &mut Window, cx: &mut Context, ) { if let Some(new_query) = self.delegate.confirm_completion(self.query(cx), window, cx) { self.set_query(new_query, window, cx); } else { cx.propagate() } } fn handle_click( &mut self, ix: usize, secondary: bool, window: &mut Window, cx: &mut Context, ) { cx.stop_propagation(); window.prevent_default(); self.set_selected_index(ix, None, false, window, cx); self.do_confirm(secondary, window, cx) } fn do_confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context) { if let Some(update_query) = self.delegate.confirm_update_query(window, cx) { self.set_query(update_query, window, cx); self.set_selected_index(0, Some(Direction::Down), false, window, cx); } else { self.delegate.confirm(secondary, window, cx) } } fn on_input_editor_event( &mut self, _: &Entity, event: &editor::EditorEvent, window: &mut Window, cx: &mut Context, ) { let Head::Editor(editor) = &self.head else { panic!("unexpected call"); }; match event { editor::EditorEvent::BufferEdited => { let query = editor.read(cx).text(cx); self.update_matches(query, window, cx); } editor::EditorEvent::Blurred => { if self.is_modal { self.cancel(&menu::Cancel, window, cx); } } _ => {} } } fn on_empty_head_blur(&mut self, window: &mut Window, cx: &mut Context) { let Head::Empty(_) = &self.head else { panic!("unexpected call"); }; self.cancel(&menu::Cancel, window, cx); } pub fn refresh_placeholder(&mut self, window: &mut Window, cx: &mut App) { match &self.head { Head::Editor(editor) => { let placeholder = self.delegate.placeholder_text(window, cx); editor.update(cx, |editor, cx| { editor.set_placeholder_text(placeholder, cx); cx.notify(); }); } Head::Empty(_) => {} } } pub fn refresh(&mut self, window: &mut Window, cx: &mut Context) { let query = self.query(cx); self.update_matches(query, window, cx); } pub fn update_matches(&mut self, query: String, window: &mut Window, cx: &mut Context) { let delegate_pending_update_matches = self.delegate.update_matches(query, window, cx); self.matches_updated(window, cx); // This struct ensures that we can synchronously drop the task returned by the // delegate's `update_matches` method and the task that the picker is spawning. // If we simply capture the delegate's task into the picker's task, when the picker's // task gets synchronously dropped, the delegate's task would keep running until // the picker's task has a chance of being scheduled, because dropping a task happens // asynchronously. self.pending_update_matches = Some(PendingUpdateMatches { delegate_update_matches: Some(delegate_pending_update_matches), _task: cx.spawn_in(window, async move |this, cx| { let delegate_pending_update_matches = this.update(cx, |this, _| { this.pending_update_matches .as_mut() .unwrap() .delegate_update_matches .take() .unwrap() })?; delegate_pending_update_matches.await; this.update_in(cx, |this, window, cx| { this.matches_updated(window, cx); }) }), }); } fn matches_updated(&mut self, window: &mut Window, cx: &mut Context) { if let ElementContainer::List(state) = &mut self.element_container { state.reset(self.delegate.match_count()); } let index = self.delegate.selected_index(); self.scroll_to_item_index(index); self.pending_update_matches = None; if let Some(secondary) = self.confirm_on_update.take() { self.do_confirm(secondary, window, cx); } cx.notify(); } pub fn query(&self, cx: &App) -> String { match &self.head { Head::Editor(editor) => editor.read(cx).text(cx), Head::Empty(_) => "".to_string(), } } pub fn set_query(&self, query: impl Into>, window: &mut Window, cx: &mut App) { if let Head::Editor(editor) = &self.head { editor.update(cx, |editor, cx| { editor.set_text(query, window, cx); let editor_offset = editor.buffer().read(cx).len(cx); editor.change_selections( SelectionEffects::scroll(Autoscroll::Next), window, cx, |s| s.select_ranges(Some(editor_offset..editor_offset)), ); }); } } fn scroll_to_item_index(&mut self, ix: usize) { match &mut self.element_container { ElementContainer::List(state) => state.scroll_to_reveal_item(ix), ElementContainer::UniformList(scroll_handle) => { scroll_handle.scroll_to_item(ix, ScrollStrategy::Top) } } } fn render_element( &self, window: &mut Window, cx: &mut Context, ix: usize, ) -> impl IntoElement + use { div() .id(("item", ix)) .cursor_pointer() .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { this.handle_click(ix, event.modifiers().secondary(), window, cx) })) // As of this writing, GPUI intercepts `ctrl-[mouse-event]`s on macOS // and produces right mouse button events. This matches platforms norms // but means that UIs which depend on holding ctrl down (such as the tab // switcher) can't be clicked on. Hence, this handler. .on_mouse_up( MouseButton::Right, cx.listener(move |this, event: &MouseUpEvent, window, cx| { // We specifically want to use the platform key here, as // ctrl will already be held down for the tab switcher. this.handle_click(ix, event.modifiers.platform, window, cx) }), ) .children(self.delegate.render_match( ix, ix == self.delegate.selected_index(), window, cx, )) .when( self.delegate.separators_after_indices().contains(&ix), |picker| { picker .border_color(cx.theme().colors().border_variant) .border_b_1() .py(px(-1.0)) }, ) } fn render_element_container(&self, cx: &mut Context) -> impl IntoElement { let sizing_behavior = if self.max_height.is_some() { ListSizingBehavior::Infer } else { ListSizingBehavior::Auto }; match &self.element_container { ElementContainer::UniformList(scroll_handle) => uniform_list( "candidates", self.delegate.match_count(), cx.processor(move |picker, visible_range: Range, window, cx| { visible_range .map(|ix| picker.render_element(window, cx, ix)) .collect() }), ) .with_sizing_behavior(sizing_behavior) .when_some(self.widest_item, |el, widest_item| { el.with_width_from_item(Some(widest_item)) }) .flex_grow() .py_1() .track_scroll(scroll_handle.clone()) .into_any_element(), ElementContainer::List(state) => list(state.clone()) .with_sizing_behavior(sizing_behavior) .flex_grow() .py_2() .into_any_element(), } } #[cfg(any(test, feature = "test-support"))] pub fn logical_scroll_top_index(&self) -> usize { match &self.element_container { ElementContainer::List(state) => state.logical_scroll_top().item_ix, ElementContainer::UniformList(scroll_handle) => { scroll_handle.logical_scroll_top_index() } } } fn hide_scrollbar(&mut self, cx: &mut Context) { const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); self.hide_scrollbar_task = Some(cx.spawn(async move |panel, cx| { cx.background_executor() .timer(SCROLLBAR_SHOW_INTERVAL) .await; panel .update(cx, |panel, cx| { panel.scrollbar_visibility = false; cx.notify(); }) .log_err(); })) } fn render_scrollbar(&self, cx: &mut Context) -> Option> { if !self.show_scrollbar || !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) { return None; } Some( div() .occlude() .id("picker-scroll") .h_full() .absolute() .right_1() .top_1() .bottom_0() .w(px(12.)) .cursor_default() .on_mouse_move(cx.listener(|_, _, _window, cx| { cx.notify(); cx.stop_propagation() })) .on_hover(|_, _window, cx| { cx.stop_propagation(); }) .on_any_mouse_down(|_, _window, cx| { cx.stop_propagation(); }) .on_mouse_up( MouseButton::Left, cx.listener(|picker, _, window, cx| { if !picker.scrollbar_state.is_dragging() && !picker.focus_handle.contains_focused(window, cx) { picker.hide_scrollbar(cx); cx.notify(); } cx.stop_propagation(); }), ) .on_scroll_wheel(cx.listener(|_, _, _window, cx| { cx.notify(); })) .children(Scrollbar::vertical(self.scrollbar_state.clone())), ) } } impl EventEmitter for Picker {} impl ModalView for Picker {} impl Render for Picker { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let editor_position = self.delegate.editor_position(); v_flex() .key_context("Picker") .size_full() .when_some(self.width, |el, width| el.w(width)) .overflow_hidden() // This is a bit of a hack to remove the modal styling when we're rendering the `Picker` // as a part of a modal rather than the entire modal. // // We should revisit how the `Picker` is styled to make it more composable. .when(self.is_modal, |this| this.elevation_3(cx)) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::editor_move_down)) .on_action(cx.listener(Self::editor_move_up)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::secondary_confirm)) .on_action(cx.listener(Self::confirm_completion)) .on_action(cx.listener(Self::confirm_input)) .children(match &self.head { Head::Editor(editor) => { if editor_position == PickerEditorPosition::Start { Some(self.delegate.render_editor(&editor.clone(), window, cx)) } else { None } } Head::Empty(empty_head) => Some(div().child(empty_head.clone())), }) .when(self.delegate.match_count() > 0, |el| { el.child( v_flex() .id("element-container") .relative() .flex_grow() .when_some(self.max_height, |div, max_h| div.max_h(max_h)) .overflow_hidden() .children(self.delegate.render_header(window, cx)) .child(self.render_element_container(cx)) .on_hover(cx.listener(|this, hovered, window, cx| { if *hovered { this.scrollbar_visibility = true; this.hide_scrollbar_task.take(); cx.notify(); } else if !this.focus_handle.contains_focused(window, cx) { this.hide_scrollbar(cx); } })) .when_some(self.render_scrollbar(cx), |div, scrollbar| { div.child(scrollbar) }), ) }) .when(self.delegate.match_count() == 0, |el| { el.when_some(self.delegate.no_matches_text(window, cx), |el, text| { el.child( v_flex().flex_grow().py_2().child( ListItem::new("empty_state") .inset(true) .spacing(ListItemSpacing::Sparse) .disabled(true) .child(Label::new(text).color(Color::Muted)), ), ) }) }) .children(self.delegate.render_footer(window, cx)) .children(match &self.head { Head::Editor(editor) => { if editor_position == PickerEditorPosition::End { Some(self.delegate.render_editor(&editor.clone(), window, cx)) } else { None } } Head::Empty(empty_head) => Some(div().child(empty_head.clone())), }) } }