From ae6cb0aff1d85ecdc5f437f925658f563afb8cb4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:21:15 -0300 Subject: [PATCH] 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. --- Cargo.lock | 2 - assets/icons/file_ignored.svg | 5 + assets/icons/file_multiple.svg | 5 + assets/keymaps/specific-overrides-macos.json | 1 + assets/keymaps/specific-overrides.json | 1 + crates/agent_ui/Cargo.toml | 1 - crates/agent_ui/src/threads_archive_view.rs | 17 -- crates/file_finder/src/file_finder.rs | 168 +++++++++++------- crates/git_ui/src/branch_picker.rs | 134 +++++++------- crates/icons/src/icons.rs | 2 + crates/picker/src/footer.rs | 28 ++- crates/picker/src/picker.rs | 147 +++++++++------ crates/picker/src/render.rs | 73 +++++++- crates/picker/src/shape.rs | 10 +- crates/recent_projects/Cargo.toml | 1 - crates/recent_projects/src/recent_projects.rs | 17 -- .../src/sidebar_recent_projects.rs | 17 -- crates/search/src/search.rs | 13 +- crates/search/src/text_finder/delegate.rs | 38 +++- 19 files changed, 389 insertions(+), 291 deletions(-) create mode 100644 assets/icons/file_ignored.svg create mode 100644 assets/icons/file_multiple.svg diff --git a/Cargo.lock b/Cargo.lock index c052ace3eb..f1a64ec5fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/assets/icons/file_ignored.svg b/assets/icons/file_ignored.svg new file mode 100644 index 0000000000..2c3228c6ad --- /dev/null +++ b/assets/icons/file_ignored.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_multiple.svg b/assets/icons/file_multiple.svg new file mode 100644 index 0000000000..739885eb65 --- /dev/null +++ b/assets/icons/file_multiple.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json index f8e4e1fb06..68cfb32f9b 100644 --- a/assets/keymaps/specific-overrides-macos.json +++ b/assets/keymaps/specific-overrides-macos.json @@ -11,6 +11,7 @@ "use_key_equivalents": true, "bindings": { "cmd-shift-a": "picker::ToggleActionsMenu", + "cmd-shift-s": "picker::ToggleMultiSelect", }, }, { diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json index 0f622554f7..494fd8d59d 100644 --- a/assets/keymaps/specific-overrides.json +++ b/assets/keymaps/specific-overrides.json @@ -10,6 +10,7 @@ "context": "Picker > Editor", "bindings": { "ctrl-shift-a": "picker::ToggleActionsMenu", + "ctrl-shift-s": "picker::ToggleMultiSelect", }, }, { diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 3ccf529738..bfabeac626 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -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 diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 51d2644ac7..a9c232c7fa 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -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, - window: &mut Window, - cx: &mut Context>, - ) -> 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() } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index f02fb67744..71c1d03efd 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -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>, ) -> Option { - 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::(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::(end_icon), - ) + fn render_match_with_checkbox( + &self, + ix: usize, + selected: bool, + checkbox: AnyElement, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + 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, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + 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 = 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 = 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::(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::(end_slot), + ) + } +} + #[derive(Clone, Debug, PartialEq, Eq)] struct PathComponentSlice<'a> { path: Cow<'a, Path>, diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 61ed9987a2..9cc86df9a7 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1201,79 +1201,81 @@ impl PickerDelegate for BranchListDelegate { editor: &Arc, _window: &mut Window, cx: &mut Context>, - ) -> Div { + ) -> Option
{ let picker = cx.weak_entity(); let editor = editor.as_any().downcast_ref::>().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 { diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 3e22896e32..3f015608b3 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -129,8 +129,10 @@ pub enum IconName { FileDoc, FileGeneric, FileGit, + FileIgnored, FileLock, FileMarkdown, + FileMultiple, FileRust, FileTextFilled, FileTextOutlined, diff --git a/crates/picker/src/footer.rs b/crates/picker/src/footer.rs index 6c0d8c8bb0..7e94e04ecf 100644 --- a/crates/picker/src/footer.rs +++ b/crates/picker/src/footer.rs @@ -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 Picker { window: &mut Window, cx: &mut Context, ) -> Option { - 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 Picker { .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 Picker { _window: &mut Window, cx: &mut Context, ) -> 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 Picker { menu })) }) + .attach(gpui::Anchor::TopRight) + .anchor(gpui::Anchor::BottomRight) + .offset(gpui::Point { + x: px(0.0), + y: px(-2.0), + }) } } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index dbc8f22160..f3c8b0a268 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -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, - 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(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, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option
{ + None } fn try_get_preview_data_for_match(&self, _cx: &App) -> Option { @@ -365,6 +355,17 @@ pub trait PickerDelegate: Sized + 'static { cx: &mut Context>, ) -> Option; + fn render_match_with_checkbox( + &self, + _ix: usize, + _selected: bool, + _checkbox: AnyElement, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + None + } + fn render_header( &self, _window: &mut Window, @@ -922,6 +923,7 @@ impl Picker { cx: &mut Context, ) { if !self.delegate.supports_multi_select() { + cx.propagate(); return; } self.select_instead_of_open = !self.select_instead_of_open; @@ -1263,12 +1265,55 @@ impl Picker { 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 Picker { })) }) .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 Picker { ) } - /// 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, ) -> 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) -> impl IntoElement { diff --git a/crates/picker/src/render.rs b/crates/picker/src/render.rs index 499b0982ee..e61425466c 100644 --- a/crates/picker/src/render.rs +++ b/crates/picker/src/render.rs @@ -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 Render for Picker { } impl Picker { + fn render_editor( + &self, + editor: &Arc, + window: &mut Window, + cx: &mut Context, + ) -> 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) -> 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 Picker { .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 Picker { .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 } diff --git a/crates/picker/src/shape.rs b/crates/picker/src/shape.rs index 217ff6a8fc..034834ef78 100644 --- a/crates/picker/src/shape.rs +++ b/crates/picker/src/shape.rs @@ -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.), }, } } diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 013e00e964..99a563aff4 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -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 diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 781d85c874..369cff1cc6 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -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, - window: &mut Window, - cx: &mut Context>, - ) -> 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() } diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index 8955e63793..006fb7f35d 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -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, - window: &mut Window, - cx: &mut Context>, - ) -> 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() } diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index dc71ec5c4a..cef989dbdf 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -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)) diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs index 195223782f..8acc4338d4 100644 --- a/crates/search/src/text_finder/delegate.rs +++ b/crates/search/src/text_finder/delegate.rs @@ -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>, ) -> Option { + 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>, + ) -> Option { + self.render_match_impl(ix, selected, Some(checkbox), window, cx) + } +} + +impl Delegate { + fn render_match_impl( + &self, + ix: usize, + selected: bool, + checkbox: Option, + _: &mut Window, + cx: &mut Context>, + ) -> Option { 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()