Allow Picker to be headless (#9099)

At the moment, `Picker` always has an editor at the top, that allows the
user to search list elements by text. Sometimes, the UI doesn't need
such an editor. Like in the [tab
switcher](https://github.com/zed-industries/zed/issues/7653) that will
confirm selection on the modifier keys release, so there will be no
searching capabilities.

This PR adds support for a "headless picker" that doesn't display an
editor. It only has an invisible element to hold input focus for
preventing it from jumping back to the workspace.

At the moment, none of the picker implementations is made headless. It's
for the future implementations. But I'd like to make it a separate PR to
keep it focused on this particular feature.

Release Notes:

- N/A

Related Issues:
- Part of #7653

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
This commit is contained in:
Andrew Lygin 2024-03-18 18:59:53 +03:00 committed by GitHub
parent ad97c357a5
commit 7512c9949c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 120 additions and 36 deletions

59
crates/picker/src/head.rs Normal file
View File

@ -0,0 +1,59 @@
use std::sync::Arc;
use editor::{Editor, EditorEvent};
use gpui::{prelude::*, AppContext, FocusHandle, FocusableView, View};
use ui::prelude::*;
/// The head of a [`Picker`](crate::Picker).
pub(crate) enum Head {
/// Picker has an editor that allows the user to filter the list.
Editor(View<Editor>),
/// Picker has no head, it's just a list of items.
Empty(View<EmptyHead>),
}
impl Head {
pub fn editor<V: 'static>(
placeholder_text: Arc<str>,
cx: &mut ViewContext<V>,
edit_handler: impl FnMut(&mut V, View<Editor>, &EditorEvent, &mut ViewContext<'_, V>) + 'static,
) -> Self {
let editor = cx.new_view(|cx| {
let mut editor = Editor::single_line(cx);
editor.set_placeholder_text(placeholder_text, cx);
editor
});
cx.subscribe(&editor, edit_handler).detach();
Self::Editor(editor)
}
pub fn empty(cx: &mut WindowContext) -> Self {
Self::Empty(cx.new_view(|cx| EmptyHead::new(cx)))
}
}
/// An invisible element that can hold focus.
pub(crate) struct EmptyHead {
focus_handle: FocusHandle,
}
impl EmptyHead {
fn new(cx: &mut ViewContext<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
}
impl Render for EmptyHead {
fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
div().track_focus(&self.focus_handle)
}
}
impl FocusableView for EmptyHead {
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}

View File

@ -5,10 +5,12 @@ use gpui::{
EventEmitter, FocusHandle, FocusableView, Length, ListState, Render, Task,
UniformListScrollHandle, View, ViewContext, WindowContext,
};
use head::Head;
use std::{sync::Arc, time::Duration};
use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
use workspace::ModalView;
mod head;
pub mod highlighted_match_with_paths;
enum ElementContainer {
@ -24,7 +26,7 @@ struct PendingUpdateMatches {
pub struct Picker<D: PickerDelegate> {
pub delegate: D,
element_container: ElementContainer,
editor: View<Editor>,
head: Head,
pending_update_matches: Option<PendingUpdateMatches>,
confirm_on_update: Option<bool>,
width: Option<Length>,
@ -84,37 +86,48 @@ pub trait PickerDelegate: Sized + 'static {
impl<D: PickerDelegate> FocusableView for Picker<D> {
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
self.editor.focus_handle(cx)
match &self.head {
Head::Editor(editor) => editor.focus_handle(cx),
Head::Empty(head) => head.focus_handle(cx),
}
}
}
fn create_editor(placeholder: Arc<str>, cx: &mut WindowContext<'_>) -> View<Editor> {
cx.new_view(|cx| {
let mut editor = Editor::single_line(cx);
editor.set_placeholder_text(placeholder, cx);
editor
})
}
impl<D: PickerDelegate> Picker<D> {
/// 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, cx: &mut ViewContext<Self>) -> Self {
Self::new(delegate, cx, true)
Self::new(delegate, cx, true, true)
}
/// 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, cx: &mut ViewContext<Self>) -> Self {
Self::new(delegate, cx, true, false)
}
/// 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, cx: &mut ViewContext<Self>) -> Self {
Self::new(delegate, cx, false)
Self::new(delegate, cx, false, true)
}
fn new(delegate: D, cx: &mut ViewContext<Self>, is_uniform: bool) -> Self {
let editor = create_editor(delegate.placeholder_text(cx), cx);
cx.subscribe(&editor, Self::on_input_editor_event).detach();
fn new(delegate: D, cx: &mut ViewContext<Self>, is_uniform: bool, is_queryable: bool) -> Self {
let head = if is_queryable {
Head::editor(
delegate.placeholder_text(cx),
cx,
Self::on_input_editor_event,
)
} else {
Head::empty(cx)
};
let mut this = Self {
delegate,
editor,
head,
element_container: Self::create_element_container(is_uniform, cx),
pending_update_matches: None,
confirm_on_update: None,
@ -123,7 +136,7 @@ impl<D: PickerDelegate> Picker<D> {
is_modal: true,
};
this.update_matches("".to_string(), cx);
// give the delegate 4ms to renderthe first set of suggestions.
// give the delegate 4ms to render the first set of suggestions.
this.delegate
.finalize_update_matches("".to_string(), Duration::from_millis(4), cx);
this
@ -167,7 +180,7 @@ impl<D: PickerDelegate> Picker<D> {
}
pub fn focus(&self, cx: &mut WindowContext) {
self.editor.update(cx, |editor, cx| editor.focus(cx));
self.focus_handle(cx).focus(cx);
}
pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
@ -269,9 +282,12 @@ impl<D: PickerDelegate> Picker<D> {
event: &editor::EditorEvent,
cx: &mut ViewContext<Self>,
) {
let Head::Editor(ref editor) = &self.head else {
panic!("unexpected call");
};
match event {
editor::EditorEvent::BufferEdited => {
let query = self.editor.read(cx).text(cx);
let query = editor.read(cx).text(cx);
self.update_matches(query, cx);
}
editor::EditorEvent::Blurred => {
@ -282,7 +298,7 @@ impl<D: PickerDelegate> Picker<D> {
}
pub fn refresh(&mut self, cx: &mut ViewContext<Self>) {
let query = self.editor.read(cx).text(cx);
let query = self.query(cx);
self.update_matches(query, cx);
}
@ -330,17 +346,22 @@ impl<D: PickerDelegate> Picker<D> {
}
pub fn query(&self, cx: &AppContext) -> String {
self.editor.read(cx).text(cx)
match &self.head {
Head::Editor(editor) => editor.read(cx).text(cx),
Head::Empty(_) => "".to_string(),
}
}
pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut ViewContext<Self>) {
self.editor.update(cx, |editor, cx| {
editor.set_text(query, cx);
let editor_offset = editor.buffer().read(cx).len(cx);
editor.change_selections(Some(Autoscroll::Next), cx, |s| {
s.select_ranges(Some(editor_offset..editor_offset))
if let Head::Editor(ref editor) = &self.head {
editor.update(cx, |editor, cx| {
editor.set_text(query, cx);
let editor_offset = editor.buffer().read(cx).len(cx);
editor.change_selections(Some(Autoscroll::Next), cx, |s| {
s.select_ranges(Some(editor_offset..editor_offset))
});
});
});
}
}
fn scroll_to_item_index(&mut self, ix: usize) {
@ -400,13 +421,6 @@ impl<D: PickerDelegate> ModalView for Picker<D> {}
impl<D: PickerDelegate> Render for Picker<D> {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let picker_editor = h_flex()
.overflow_hidden()
.flex_none()
.h_9()
.px_4()
.child(self.editor.clone());
div()
.key_context("Picker")
.size_full()
@ -425,8 +439,19 @@ impl<D: PickerDelegate> Render for Picker<D> {
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::secondary_confirm))
.on_action(cx.listener(Self::use_selected_query))
.child(picker_editor)
.child(Divider::horizontal())
.child(match &self.head {
Head::Editor(editor) => v_flex()
.child(
h_flex()
.overflow_hidden()
.flex_none()
.h_9()
.px_4()
.child(editor.clone()),
)
.child(Divider::horizontal()),
Head::Empty(empty_head) => div().child(empty_head.clone()),
})
.when(self.delegate.match_count() > 0, |el| {
el.child(
v_flex()