picker: Improve multi-select discoverability and rendering (#60919)
This PR reworks how multi-select mode is rendered in pickers (only used by the file finder and text finder). The "Multi Select" entry previously lived inside the footer's Actions menu, which was hard to discover and its toggle checkmark misaligned the menu's keybinding column. This PR changes to an icon button at the far-roght edge of the search editor, with a tooltip showing the keybinding to toggle it. Also made each list item use the actual Checkbox component instead of a bespoke re-implementation of it. And in doing so, added a few design improvements to the list item so that it received the checkbox while preserving proper styles for each interaction state, as well as displaying the keybinding to select the item or check the item. Here's a quick video, showing these changes off: https://github.com/user-attachments/assets/e142f7d1-87ba-4258-844b-953ed06237bd Release Notes: - Improved multi-select in the file finder and text finder: the toggle now lives in the search bar with a `cmd-shift-s` keybinding, and selection checkboxes render inside list items.
This commit is contained in:
1 parent
ef94f98dbb
commit
ae6cb0aff1
19 files changed
+389
-291
No files matched your search
Generated
-2
@@ -550,7 +550,6 @@ dependencies = [
|
||||
"time",
|
||||
"tree-sitter-md",
|
||||
"ui",
|
||||
"ui_input",
|
||||
"unicode-script",
|
||||
"unicode-segmentation",
|
||||
"unindent",
|
||||
@@ -14960,7 +14959,6 @@ dependencies = [
|
||||
"task",
|
||||
"telemetry",
|
||||
"ui",
|
||||
"ui_input",
|
||||
"util",
|
||||
"windows-registry 0.6.1",
|
||||
"workspace",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.24139 2.86957L14.7586 13.1304" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.5 7.63104V12.8C3.5 13.1182 3.61853 13.4235 3.8295 13.6485C4.04048 13.8735 4.32663 14 4.625 14H11.0079" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.625 2H8.60442C9.29544 2 9.95564 2.28602 10.4283 2.79015L11.8238 4.27877C12.2582 4.74213 12.5 5.35347 12.5 5.98862V7.97886" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 651 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.50458 4V6.43334C9.50458 6.77846 9.64168 7.10944 9.88571 7.35348C10.1298 7.59752 10.4607 7.73462 10.8059 7.73462H13.3" stroke="#DCE0E5" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<path d="M11.5735 4.65846C11.1797 4.23835 10.6295 4 10.0537 4H6.73749C6.48885 4 6.25039 4.10536 6.07458 4.29289C5.89876 4.48043 5.79999 4.73478 5.79999 5V13C5.79999 13.2652 5.89876 13.5196 6.07458 13.7071C6.25039 13.8946 6.48885 14 6.73749 14H12.3625C12.6111 14 12.8496 13.8946 13.0254 13.7071C13.2012 13.5196 13.3 13.2652 13.3 13V7.32385C13.3 6.79456 13.0985 6.28511 12.7365 5.89897L11.5735 4.65846Z" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.5 12H3.73749C3.48885 12 3.25039 11.8946 3.07458 11.7071C2.89876 11.5196 2.79999 11.2652 2.79999 11V3C2.79999 2.73478 2.89876 2.48043 3.07458 2.29289C3.25039 2.10536 3.48885 2 3.73749 2H7.05367C7.62952 2 8.17969 2.23835 8.57354 2.65846L9.83124 4" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -11,6 +11,7 @@
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-shift-a": "picker::ToggleActionsMenu",
|
||||
"cmd-shift-s": "picker::ToggleMultiSelect",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"context": "Picker > Editor",
|
||||
"bindings": {
|
||||
"ctrl-shift-a": "picker::ToggleActionsMenu",
|
||||
"ctrl-shift-s": "picker::ToggleMultiSelect",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -108,7 +108,6 @@ theme.workspace = true
|
||||
theme_settings.workspace = true
|
||||
time.workspace = true
|
||||
ui.workspace = true
|
||||
ui_input.workspace = true
|
||||
unicode-script.workspace = true
|
||||
unicode-segmentation.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
@@ -36,7 +36,6 @@ use ui::{
|
||||
Scrollbars, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*,
|
||||
utils::platform_title_bar_height,
|
||||
};
|
||||
use ui_input::ErasedEditor;
|
||||
use util::ResultExt;
|
||||
use util::paths::PathExt;
|
||||
use workspace::{
|
||||
@@ -1302,22 +1301,6 @@ impl PickerDelegate for ProjectPickerDelegate {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn render_editor(
|
||||
&self,
|
||||
editor: &Arc<dyn ErasedEditor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Div {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.h_9()
|
||||
.px_2p5()
|
||||
.justify_between()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(editor.render(window, cx))
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_entries.len()
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ use file_icons::FileIcons;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use fuzzy_nucleo::{PathMatch, PathMatchCandidate};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, Context, DismissEvent, Empty, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task,
|
||||
TaskExt, WeakEntity, Window, actions, rems,
|
||||
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, TaskExt,
|
||||
WeakEntity, Window, actions, rems,
|
||||
};
|
||||
use language::{BufferSnapshot, Point};
|
||||
use open_path_prompt::{
|
||||
@@ -40,7 +40,7 @@ use std::{
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use ui::{HighlightedLabel, Indicator, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
||||
use ui::{Checkbox, HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
||||
use util::{
|
||||
ResultExt, maybe,
|
||||
paths::{PathStyle, PathWithPosition},
|
||||
@@ -1768,12 +1768,9 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
"Include Ignored Files"
|
||||
};
|
||||
|
||||
let filter_button = IconButton::new("filter-ignored", IconName::Sliders)
|
||||
let filter_button = IconButton::new("filter-ignored", IconName::FileIgnored)
|
||||
.icon_size(IconSize::Small)
|
||||
.toggle_state(including_ignored)
|
||||
.when(self.include_ignored.is_some(), |this| {
|
||||
this.indicator(Indicator::dot().color(Color::Info))
|
||||
})
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::for_action_in(tooltip_label, &ToggleIncludeIgnored, &focus_handle, cx)
|
||||
})
|
||||
@@ -2034,65 +2031,18 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let settings = FileFinderSettings::get_global(cx);
|
||||
self.render_match_impl(ix, selected, None, window, cx)
|
||||
}
|
||||
|
||||
let path_match = self.matches.get(ix)?;
|
||||
|
||||
let end_icon = match path_match {
|
||||
Match::History { .. } => Icon::new(IconName::HistoryRerun)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::Small)
|
||||
.into_any_element(),
|
||||
Match::Search(_) => v_flex()
|
||||
.flex_none()
|
||||
.size(IconSize::Small.rems())
|
||||
.into_any_element(),
|
||||
Match::Channel { .. } => v_flex()
|
||||
.flex_none()
|
||||
.size(IconSize::Small.rems())
|
||||
.into_any_element(),
|
||||
Match::CreateNew(_) => Empty.into_any_element(),
|
||||
};
|
||||
|
||||
let is_create_new = matches!(path_match, Match::CreateNew(_));
|
||||
|
||||
let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
|
||||
|
||||
let file_icon = match path_match {
|
||||
Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
|
||||
_ => maybe!({
|
||||
if !settings.file_icons {
|
||||
return None;
|
||||
}
|
||||
let abs_path = path_match.abs_path(&self.project, cx)?;
|
||||
let file_name = abs_path.file_name()?;
|
||||
let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
|
||||
Some(Icon::from_path(icon).color(Color::Muted))
|
||||
}),
|
||||
};
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.map(|this| {
|
||||
if is_create_new {
|
||||
this.start_slot(Icon::new(IconName::Plus).size(IconSize::Small))
|
||||
} else {
|
||||
this.start_slot::<Icon>(file_icon)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1p5()
|
||||
.child(file_name_label.truncate_middle())
|
||||
.child(full_path_label.truncate_start()),
|
||||
)
|
||||
.end_slot::<AnyElement>(end_icon),
|
||||
)
|
||||
fn render_match_with_checkbox(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
checkbox: AnyElement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
self.render_match_impl(ix, selected, Some(checkbox), window, cx)
|
||||
}
|
||||
|
||||
fn actions_menu(
|
||||
@@ -2117,6 +2067,92 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
impl FileFinderDelegate {
|
||||
fn render_match_impl(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
checkbox: Option<AnyElement>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<ListItem> {
|
||||
let settings = FileFinderSettings::get_global(cx);
|
||||
|
||||
let path_match = self.matches.get(ix)?;
|
||||
|
||||
let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
|
||||
|
||||
let start_icon = match path_match {
|
||||
Match::CreateNew(_) => Some(Icon::new(IconName::Plus).size(IconSize::Small)),
|
||||
Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
|
||||
_ => maybe!({
|
||||
if !settings.file_icons {
|
||||
return None;
|
||||
}
|
||||
let abs_path = path_match.abs_path(&self.project, cx)?;
|
||||
let file_name = abs_path.file_name()?;
|
||||
let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
|
||||
Some(Icon::from_path(icon).color(Color::Muted))
|
||||
}),
|
||||
};
|
||||
|
||||
let checkbox = checkbox.map(|checkbox| {
|
||||
if matches!(path_match, Match::CreateNew(_) | Match::Channel { .. }) {
|
||||
div()
|
||||
.flex_none()
|
||||
.size(Checkbox::container_size())
|
||||
.into_any_element()
|
||||
} else {
|
||||
checkbox
|
||||
}
|
||||
});
|
||||
|
||||
let start_slot: Option<AnyElement> = match (checkbox, start_icon) {
|
||||
(Some(checkbox), icon) => Some(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(checkbox)
|
||||
.children(icon)
|
||||
.into_any_element(),
|
||||
),
|
||||
(None, icon) => icon.map(IntoElement::into_any_element),
|
||||
};
|
||||
|
||||
let end_slot: Option<AnyElement> = match path_match {
|
||||
Match::History { .. } => Some(
|
||||
Icon::new(IconName::HistoryRerun)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::Small)
|
||||
.into_any_element(),
|
||||
),
|
||||
Match::Search(_) | Match::Channel { .. } => Some(
|
||||
div()
|
||||
.flex_none()
|
||||
.size(IconSize::Small.rems())
|
||||
.into_any_element(),
|
||||
),
|
||||
Match::CreateNew(_) => None,
|
||||
};
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.start_slot::<AnyElement>(start_slot)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1p5()
|
||||
.child(file_name_label.truncate_middle())
|
||||
.child(full_path_label.truncate_start()),
|
||||
)
|
||||
.end_slot::<AnyElement>(end_slot),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct PathComponentSlice<'a> {
|
||||
path: Cow<'a, Path>,
|
||||
|
||||
@@ -1201,79 +1201,81 @@ impl PickerDelegate for BranchListDelegate {
|
||||
editor: &Arc<dyn ErasedEditor>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Div {
|
||||
) -> Option<Div> {
|
||||
let picker = cx.weak_entity();
|
||||
let editor = editor.as_any().downcast_ref::<Entity<Editor>>().unwrap();
|
||||
|
||||
let show_inline_filter =
|
||||
self.editor_position() == PickerEditorPosition::End || !self.show_footer;
|
||||
|
||||
v_flex()
|
||||
.when(
|
||||
self.editor_position() == PickerEditorPosition::End,
|
||||
|this| this.child(Divider::horizontal()),
|
||||
)
|
||||
.when_some(self.branch_list_error.clone(), |this, error| {
|
||||
let message = format!("Some branches could not be loaded: {error}");
|
||||
this.child(
|
||||
div()
|
||||
.id("branch-list-error")
|
||||
.p_1p5()
|
||||
.child(
|
||||
Banner::new().severity(Severity::Warning).child(
|
||||
Label::new(message.clone())
|
||||
.size(LabelSize::Small)
|
||||
.single_line()
|
||||
.truncate(),
|
||||
),
|
||||
)
|
||||
.tooltip(Tooltip::text(message)),
|
||||
Some(
|
||||
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(show_inline_filter, |this| {
|
||||
let branch_filter = self.branch_filter;
|
||||
let picker_for_menu_open = picker.clone();
|
||||
this.gap_1().justify_between().child(
|
||||
PopoverMenu::new("branch-filter-menu")
|
||||
.with_handle(self.branch_filter_menu_handle.clone())
|
||||
.on_open(Rc::new(move |_, cx| {
|
||||
picker_for_menu_open
|
||||
.update(cx, |picker, _| {
|
||||
picker.delegate.branch_filter_menu_open = true;
|
||||
})
|
||||
.log_err();
|
||||
}))
|
||||
.trigger(
|
||||
IconButton::new("branch-filter", IconName::ListFilter)
|
||||
.toggle_state(branch_filter != BranchFilter::All)
|
||||
.when(branch_filter != BranchFilter::All, |this| {
|
||||
this.indicator(Indicator::dot().color(Color::Info))
|
||||
})
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Filter branches")),
|
||||
)
|
||||
.menu(move |window, cx| {
|
||||
Some(branch_filter_menu(
|
||||
branch_filter,
|
||||
picker.clone(),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(
|
||||
self.editor_position() == PickerEditorPosition::Start,
|
||||
|this| this.child(Divider::horizontal()),
|
||||
)
|
||||
.when_some(self.branch_list_error.clone(), |this, error| {
|
||||
let message = format!("Some branches could not be loaded: {error}");
|
||||
this.child(
|
||||
div()
|
||||
.id("branch-list-error")
|
||||
.p_1p5()
|
||||
.child(
|
||||
Banner::new().severity(Severity::Warning).child(
|
||||
Label::new(message.clone())
|
||||
.size(LabelSize::Small)
|
||||
.single_line()
|
||||
.truncate(),
|
||||
),
|
||||
)
|
||||
.tooltip(Tooltip::text(message)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.overflow_hidden()
|
||||
.flex_none()
|
||||
.h_9()
|
||||
.px_2p5()
|
||||
.child(editor.clone())
|
||||
.when(show_inline_filter, |this| {
|
||||
let branch_filter = self.branch_filter;
|
||||
let picker_for_menu_open = picker.clone();
|
||||
this.gap_1().justify_between().child(
|
||||
PopoverMenu::new("branch-filter-menu")
|
||||
.with_handle(self.branch_filter_menu_handle.clone())
|
||||
.on_open(Rc::new(move |_, cx| {
|
||||
picker_for_menu_open
|
||||
.update(cx, |picker, _| {
|
||||
picker.delegate.branch_filter_menu_open = true;
|
||||
})
|
||||
.log_err();
|
||||
}))
|
||||
.trigger(
|
||||
IconButton::new("branch-filter", IconName::ListFilter)
|
||||
.toggle_state(branch_filter != BranchFilter::All)
|
||||
.when(branch_filter != BranchFilter::All, |this| {
|
||||
this.indicator(Indicator::dot().color(Color::Info))
|
||||
})
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Filter branches")),
|
||||
)
|
||||
.menu(move |window, cx| {
|
||||
Some(branch_filter_menu(
|
||||
branch_filter,
|
||||
picker.clone(),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(
|
||||
self.editor_position() == PickerEditorPosition::Start,
|
||||
|this| this.child(Divider::horizontal()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn editor_position(&self) -> PickerEditorPosition {
|
||||
|
||||
@@ -129,8 +129,10 @@ pub enum IconName {
|
||||
FileDoc,
|
||||
FileGeneric,
|
||||
FileGit,
|
||||
FileIgnored,
|
||||
FileLock,
|
||||
FileMarkdown,
|
||||
FileMultiple,
|
||||
FileRust,
|
||||
FileTextFilled,
|
||||
FileTextOutlined,
|
||||
|
||||
+10
-18
@@ -8,7 +8,6 @@ use crate::PickerDelegate;
|
||||
use crate::SetPreviewBelow;
|
||||
use crate::SetPreviewRight;
|
||||
use crate::ToggleActionsMenu;
|
||||
use crate::ToggleMultiSelect;
|
||||
use crate::TogglePreview;
|
||||
use crate::preview;
|
||||
|
||||
@@ -98,18 +97,8 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
let mut actions = self.delegate.actions_menu(window, cx);
|
||||
// The multi-select entry is picker-owned so it can reflect the mode,
|
||||
// which delegates don't know about.
|
||||
if self.delegate.supports_multi_select() {
|
||||
if !actions.is_empty() {
|
||||
actions.push(PickerAction::separator());
|
||||
}
|
||||
actions.push(
|
||||
PickerAction::button("Multi Select", ToggleMultiSelect.boxed_clone())
|
||||
.toggled(self.select_instead_of_open),
|
||||
);
|
||||
}
|
||||
let actions = self.delegate.actions_menu(window, cx);
|
||||
|
||||
if self.preview.is_none() && actions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -123,9 +112,9 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
.justify_between()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(div().when(self.preview.is_some(), |this| {
|
||||
.when(self.preview.is_some(), |this| {
|
||||
this.child(self.render_preview_controls(window, cx))
|
||||
}))
|
||||
})
|
||||
.when(!actions.is_empty(), |this| {
|
||||
this.child(self.render_actions_button(actions.into(), focus_handle, window, cx))
|
||||
})
|
||||
@@ -206,11 +195,8 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let _ = cx;
|
||||
PopoverMenu::new("picker-actions-menu")
|
||||
.with_handle(self.actions_menu_handle.clone())
|
||||
.attach(gpui::Anchor::TopRight)
|
||||
.anchor(gpui::Anchor::BottomRight)
|
||||
.trigger(
|
||||
Button::new("picker-actions-trigger", "Actions…")
|
||||
.key_binding(
|
||||
@@ -230,5 +216,11 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
menu
|
||||
}))
|
||||
})
|
||||
.attach(gpui::Anchor::TopRight)
|
||||
.anchor(gpui::Anchor::BottomRight)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(-2.0),
|
||||
})
|
||||
}
|
||||
}
|
||||
+88
-59
@@ -11,7 +11,10 @@ use serde::Deserialize;
|
||||
use std::{
|
||||
cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration,
|
||||
};
|
||||
use ui::{ContextMenu, Divider, DocumentationAside, PopoverMenuHandle, prelude::*, v_flex};
|
||||
use ui::{
|
||||
Checkbox, ContextMenu, Divider, DocumentationAside, KeyBinding, PopoverMenuHandle, Tooltip,
|
||||
prelude::*,
|
||||
};
|
||||
use ui_input::ErasedEditorEvent;
|
||||
use util::ResultExt;
|
||||
use workspace::ModalView;
|
||||
@@ -323,30 +326,17 @@ pub trait PickerDelegate: Sized + 'static {
|
||||
None
|
||||
}
|
||||
|
||||
/// Overrides the search bar entirely. Most delegates should return `None`
|
||||
/// to get the picker-rendered default (which includes
|
||||
/// [`Self::searchbar_trailer`] and the multi-select toggle); override for
|
||||
/// full control over the search bar.
|
||||
fn render_editor(
|
||||
&self,
|
||||
editor: &Arc<dyn ErasedEditor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> 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(div().flex_1().child(editor.render(window, cx)))
|
||||
.children(self.searchbar_trailer(window, cx)),
|
||||
)
|
||||
.when(
|
||||
self.editor_position() == PickerEditorPosition::Start,
|
||||
|this| this.child(Divider::horizontal()),
|
||||
)
|
||||
_editor: &Arc<dyn ErasedEditor>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Div> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_get_preview_data_for_match(&self, _cx: &App) -> Option<PreviewUpdate> {
|
||||
@@ -365,6 +355,17 @@ pub trait PickerDelegate: Sized + 'static {
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem>;
|
||||
|
||||
fn render_match_with_checkbox(
|
||||
&self,
|
||||
_ix: usize,
|
||||
_selected: bool,
|
||||
_checkbox: AnyElement,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
None
|
||||
}
|
||||
|
||||
fn render_header(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
@@ -922,6 +923,7 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.delegate.supports_multi_select() {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
self.select_instead_of_open = !self.select_instead_of_open;
|
||||
@@ -1263,12 +1265,55 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
let is_multi_selected = supports_multi_select && self.delegate.is_item_selected(ix);
|
||||
let multi_select_active = supports_multi_select && self.select_instead_of_open;
|
||||
|
||||
let item_with_checkbox = if multi_select_active && selectable {
|
||||
let checkbox = self
|
||||
.render_multi_select_indicator(ix, is_multi_selected, cx)
|
||||
.into_any_element();
|
||||
self.delegate.render_match_with_checkbox(
|
||||
ix,
|
||||
ix == self.delegate.selected_index(),
|
||||
checkbox,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let use_fallback_indicator =
|
||||
multi_select_active && selectable && item_with_checkbox.is_none();
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
div()
|
||||
.id(("item", ix))
|
||||
.when(selectable, |this| this.cursor_pointer())
|
||||
.when(selectable && multi_select_active, |this| {
|
||||
.when(use_fallback_indicator, |this| {
|
||||
this.hover(|s| s.bg(cx.theme().colors().ghost_element_hover))
|
||||
})
|
||||
.when(multi_select_active && selectable, |this| {
|
||||
this.tooltip(Tooltip::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(KeyBinding::for_action_in(
|
||||
&MultiSelectNext,
|
||||
&focus_handle,
|
||||
cx,
|
||||
))
|
||||
.child(Label::new("Select")),
|
||||
)
|
||||
.child(Divider::vertical())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx))
|
||||
.child(Label::new("Open")),
|
||||
)
|
||||
.into_any_element()
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _window, _cx| {
|
||||
@@ -1310,17 +1355,19 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
}))
|
||||
})
|
||||
.map(|row| {
|
||||
// Pickers without multi-select keep their element tree
|
||||
// unchanged.
|
||||
if supports_multi_select {
|
||||
if let Some(item) = item_with_checkbox {
|
||||
row.child(item)
|
||||
} else if supports_multi_select {
|
||||
row.child(
|
||||
h_flex()
|
||||
// Headers and separators cannot be part of the
|
||||
// selection, so they get no indicator.
|
||||
.when(multi_select_active && selectable, |this| {
|
||||
this.child(
|
||||
self.render_multi_select_indicator(is_multi_selected, cx),
|
||||
)
|
||||
.when(use_fallback_indicator, |this| {
|
||||
this.child(self.render_multi_select_indicator(
|
||||
ix,
|
||||
is_multi_selected,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.children(self.delegate.render_match(
|
||||
ix,
|
||||
@@ -1349,39 +1396,21 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
)
|
||||
}
|
||||
|
||||
/// The checkbox in front of items while in multi-select mode.
|
||||
fn render_multi_select_indicator(
|
||||
&self,
|
||||
ix: usize,
|
||||
is_selected: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.w_6()
|
||||
.flex_none()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.size_4()
|
||||
.flex_none()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(if is_selected {
|
||||
cx.theme().colors().border_focused
|
||||
} else {
|
||||
cx.theme().colors().border
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.when(is_selected, |this| {
|
||||
this.bg(cx.theme().colors().element_selected).child(
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Accent),
|
||||
)
|
||||
}),
|
||||
)
|
||||
Checkbox::new(("picker-multi-select-checkbox", ix), is_selected.into())
|
||||
.fill()
|
||||
.elevation(ui::ElevationIndex::ModalSurface)
|
||||
.on_click(cx.listener(move |this, _: &ui::ToggleState, window, cx| {
|
||||
// Don't let the row's click handler also toggle the item.
|
||||
cx.stop_propagation();
|
||||
this.delegate.toggle_item_selected(ix, window, cx);
|
||||
cx.notify();
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_element_container(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
|
||||
@@ -10,12 +10,17 @@ use ui::{
|
||||
|
||||
use crate::shape::Shape;
|
||||
use crate::{
|
||||
ElementContainer, Picker, PickerDelegate, PickerEditorPosition, Preview,
|
||||
ElementContainer, Picker, PickerDelegate, PickerEditorPosition, Preview, ToggleMultiSelect,
|
||||
head::Head,
|
||||
preview::Layout,
|
||||
render::window_controls::{Bottom, Left, LeftCorner, Middle, Right, RightCorner},
|
||||
};
|
||||
use crate::{persistence, preview};
|
||||
use gpui::Action as _;
|
||||
use gpui::Focusable as _;
|
||||
use std::sync::Arc;
|
||||
use ui::{Divider, Tooltip, prelude::*};
|
||||
use ui_input::ErasedEditor;
|
||||
|
||||
pub mod window_controls;
|
||||
|
||||
@@ -88,6 +93,54 @@ impl<D: PickerDelegate> Render for Picker<D> {
|
||||
}
|
||||
|
||||
impl<D: PickerDelegate> Picker<D> {
|
||||
fn render_editor(
|
||||
&self,
|
||||
editor: &Arc<dyn ErasedEditor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> gpui::Div {
|
||||
if let Some(custom) = self.delegate.render_editor(editor, window, cx) {
|
||||
return custom;
|
||||
}
|
||||
let editor_position = self.delegate.editor_position();
|
||||
|
||||
v_flex()
|
||||
.when(editor_position == PickerEditorPosition::End, |this| {
|
||||
this.child(Divider::horizontal())
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.h_9()
|
||||
.px_2p5()
|
||||
.flex_none()
|
||||
.overflow_hidden()
|
||||
.child(div().flex_1().child(editor.render(window, cx)))
|
||||
.children(self.delegate.searchbar_trailer(window, cx))
|
||||
.when(self.delegate.supports_multi_select(), |this| {
|
||||
this.child(self.render_multi_select_toggle(cx))
|
||||
}),
|
||||
)
|
||||
.when(editor_position == PickerEditorPosition::Start, |this| {
|
||||
this.child(Divider::horizontal())
|
||||
})
|
||||
}
|
||||
|
||||
/// The multi-select toggle is picker-owned so it can reflect the mode,
|
||||
/// which delegates don't know about.
|
||||
fn render_multi_select_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let active = self.select_instead_of_open;
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
IconButton::new("picker-multi-select-toggle", IconName::FileMultiple)
|
||||
.icon_size(IconSize::Small)
|
||||
.toggle_state(active)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::for_action_in("Toggle Multi Select", &ToggleMultiSelect, &focus_handle, cx)
|
||||
})
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.dispatch_action(ToggleMultiSelect.boxed_clone(), cx);
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn render_results(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
@@ -154,13 +207,14 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
.children(match &self.head {
|
||||
Head::Editor(editor) => {
|
||||
if editor_position == PickerEditorPosition::Start {
|
||||
Some(h_flex().w_full().child(
|
||||
div().flex_1().child(self.delegate.render_editor(
|
||||
&editor.clone(),
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
))
|
||||
let editor = editor.clone();
|
||||
Some(
|
||||
h_flex().w_full().child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(self.render_editor(&editor, window, cx)),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -220,7 +274,8 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
.children(match &self.head {
|
||||
Head::Editor(editor) => {
|
||||
if editor_position == PickerEditorPosition::End {
|
||||
Some(self.delegate.render_editor(&editor.clone(), window, cx))
|
||||
let editor = editor.clone();
|
||||
Some(self.render_editor(&editor, window, cx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::Window;
|
||||
use gpui::{Pixels, Rems, Size};
|
||||
use ui::{Div, Styled};
|
||||
use ui::{Div, Styled, rems_from_px};
|
||||
|
||||
use crate::preview::Layout;
|
||||
|
||||
@@ -242,12 +242,12 @@ impl Default for SizeBounds {
|
||||
// over the lower bar so clear another 5 rems there.
|
||||
max_height: (RelativeHeight::FULL - Rems(10.0)) * 0.95,
|
||||
min_results: Size {
|
||||
width: Rems(15.0),
|
||||
height: Rems(20.0),
|
||||
width: rems_from_px(280.),
|
||||
height: rems_from_px(320.),
|
||||
},
|
||||
min_preview: Size {
|
||||
width: Rems(8.0),
|
||||
height: Rems(6.0),
|
||||
width: rems_from_px(128.),
|
||||
height: rems_from_px(96.),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ settings.workspace = true
|
||||
task.workspace = true
|
||||
telemetry.workspace = true
|
||||
ui.workspace = true
|
||||
ui_input.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
worktree.workspace = true
|
||||
|
||||
@@ -36,7 +36,6 @@ use project::{Worktree, git_store::Repository};
|
||||
pub use remote_connections::RemoteSettings;
|
||||
pub use remote_servers::RemoteServerProjects;
|
||||
use settings::{DefaultOpenBehavior, Settings, WorktreeId};
|
||||
use ui_input::ErasedEditor;
|
||||
use workspace::ProjectGroupKey;
|
||||
|
||||
use dev_container::{DevContainerContext, find_devcontainer_configs};
|
||||
@@ -938,22 +937,6 @@ impl PickerDelegate for RecentProjectsDelegate {
|
||||
"Search projects…".into()
|
||||
}
|
||||
|
||||
fn render_editor(
|
||||
&self,
|
||||
editor: &Arc<dyn ErasedEditor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Div {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.h_9()
|
||||
.px_2p5()
|
||||
.justify_between()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(editor.render(window, cx))
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_entries.len()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use picker::{
|
||||
use remote::RemoteConnectionOptions;
|
||||
use settings::Settings;
|
||||
use ui::{ButtonLike, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
||||
use ui_input::ErasedEditor;
|
||||
use util::{ResultExt, paths::PathExt};
|
||||
use workspace::{
|
||||
MultiWorkspace, OpenMode, OpenOptions, ProjectGroupKey, RecentWorkspace,
|
||||
@@ -144,22 +143,6 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
|
||||
"Search projects…".into()
|
||||
}
|
||||
|
||||
fn render_editor(
|
||||
&self,
|
||||
editor: &Arc<dyn ErasedEditor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Div {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.h_9()
|
||||
.px_2p5()
|
||||
.justify_between()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(editor.render(window, cx))
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_workspaces.len()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ use editor::SearchSettings;
|
||||
use gpui::{Action, App, ClickEvent, Entity, FocusHandle, IntoElement, actions};
|
||||
use project::search::SearchQuery;
|
||||
pub use project_search::ProjectSearchView;
|
||||
use ui::{ButtonStyle, IconButton, IconButtonShape};
|
||||
use ui::{Tooltip, prelude::*};
|
||||
use ui::{IconButtonShape, Tooltip, prelude::*};
|
||||
use util::paths::PathMatcher;
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::{Toast, Workspace};
|
||||
@@ -115,10 +114,10 @@ impl SearchOption {
|
||||
|
||||
pub fn icon(&self) -> ui::IconName {
|
||||
match self {
|
||||
SearchOption::WholeWord => ui::IconName::WholeWord,
|
||||
SearchOption::CaseSensitive => ui::IconName::CaseSensitive,
|
||||
SearchOption::IncludeIgnored => ui::IconName::Sliders,
|
||||
SearchOption::Regex => ui::IconName::Regex,
|
||||
SearchOption::WholeWord => IconName::WholeWord,
|
||||
SearchOption::CaseSensitive => IconName::CaseSensitive,
|
||||
SearchOption::IncludeIgnored => IconName::FileIgnored,
|
||||
SearchOption::Regex => IconName::Regex,
|
||||
_ => panic!("{self:?} is not a named SearchOption"),
|
||||
}
|
||||
}
|
||||
@@ -141,6 +140,7 @@ impl SearchOption {
|
||||
) -> impl IntoElement {
|
||||
let action = self.to_toggle_action();
|
||||
let label = self.label();
|
||||
|
||||
IconButton::new(
|
||||
(label, matches!(search_source, SearchSource::Buffer) as u32),
|
||||
self.icon(),
|
||||
@@ -162,7 +162,6 @@ impl SearchOption {
|
||||
}))
|
||||
}
|
||||
})
|
||||
.style(ButtonStyle::Subtle)
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(active.contains(self.as_options()))
|
||||
.tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx))
|
||||
|
||||
@@ -45,8 +45,8 @@ use smol::future::yield_now;
|
||||
use text::Anchor;
|
||||
use theme_settings::ThemeSettings;
|
||||
use ui::{
|
||||
Disclosure, Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable,
|
||||
Tooltip, prelude::*, text_for_keystroke,
|
||||
Disclosure, Divider, FluentBuilder, ListItem, ListItemSpacing, Toggleable, Tooltip, prelude::*,
|
||||
text_for_keystroke,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use workspace::SplitDirection;
|
||||
@@ -719,12 +719,12 @@ impl PickerDelegate for Delegate {
|
||||
let label = option.label();
|
||||
let focus_handle = focus_handle.clone();
|
||||
let picker = picker.clone();
|
||||
|
||||
IconButton::new(
|
||||
("text-finder-search-option", option as usize),
|
||||
option.icon(),
|
||||
)
|
||||
.icon_size(IconSize::Small)
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(active.contains(options))
|
||||
.tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -737,8 +737,9 @@ impl PickerDelegate for Delegate {
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.gap_px()
|
||||
.children(filter_buttons)
|
||||
.child(Divider::vertical().ml_px().mr_0p5())
|
||||
.children(picker::parts::project_scan_indicator(
|
||||
self.active_query.is_some(),
|
||||
self.project(cx),
|
||||
@@ -772,7 +773,7 @@ impl PickerDelegate for Delegate {
|
||||
picker::PickerAction::separator(),
|
||||
picker::PickerAction::button(
|
||||
if self.selected_matches.len() > 1 {
|
||||
"Open multiple"
|
||||
"Open Multiple"
|
||||
} else {
|
||||
"Open File"
|
||||
},
|
||||
@@ -1009,9 +1010,33 @@ impl PickerDelegate for Delegate {
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
self.render_match_impl(ix, selected, None, window, cx)
|
||||
}
|
||||
|
||||
fn render_match_with_checkbox(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
checkbox: AnyElement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
self.render_match_impl(ix, selected, Some(checkbox), window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Delegate {
|
||||
fn render_match_impl(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
checkbox: Option<AnyElement>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<AnyElement> {
|
||||
match self.entries.get(ix)? {
|
||||
Entry::Separator => Some(
|
||||
div()
|
||||
@@ -1129,6 +1154,7 @@ impl PickerDelegate for Delegate {
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.when_some(checkbox, |this, checkbox| this.start_slot(checkbox))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
|
||||
Reference in new issue
Block a user