Structure the contact finder more similarly to the channel modal

Max Brunsfeld and Mikayla created

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

crates/collab_ui/src/collab_panel.rs                |   6 
crates/collab_ui/src/collab_panel/channel_modal.rs  |  20 +
crates/collab_ui/src/collab_panel/contact_finder.rs | 141 +++++++++++-
crates/theme/src/theme.rs                           |  23 +
crates/vcs_menu/src/lib.rs                          |   2 
styles/src/style_tree/app.ts                        |   1 
styles/src/style_tree/channel_modal.ts              | 153 --------------
styles/src/style_tree/collab_modals.ts              | 159 +++++++++++++++
styles/src/style_tree/collab_panel.ts               |   6 
styles/src/style_tree/contact_finder.ts             |  72 +++---
10 files changed, 351 insertions(+), 232 deletions(-)

Detailed changes

crates/collab_ui/src/collab_panel.rs 🔗

@@ -7,7 +7,7 @@ use call::ActiveCall;
 use client::{
     proto::PeerId, Channel, ChannelEvent, ChannelId, ChannelStore, Client, Contact, User, UserStore,
 };
-use contact_finder::build_contact_finder;
+
 use context_menu::{ContextMenu, ContextMenuItem};
 use db::kvp::KEY_VALUE_STORE;
 use editor::{Cancel, Editor};
@@ -46,6 +46,8 @@ use workspace::{
 use crate::face_pile::FacePile;
 use channel_modal::ChannelModal;
 
+use self::contact_finder::ContactFinder;
+
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 struct RemoveChannel {
     channel_id: u64,
@@ -1945,7 +1947,7 @@ impl CollabPanel {
             workspace.update(cx, |workspace, cx| {
                 workspace.toggle_modal(cx, |_, cx| {
                     cx.add_view(|cx| {
-                        let finder = build_contact_finder(self.user_store.clone(), cx);
+                        let mut finder = ContactFinder::new(self.user_store.clone(), cx);
                         finder.set_query(self.filter_editor.read(cx).text(cx), cx);
                         finder
                     })

crates/collab_ui/src/collab_panel/channel_modal.rs 🔗

@@ -66,7 +66,7 @@ impl ChannelModal {
                 },
                 cx,
             )
-            .with_theme(|theme| theme.collab_panel.channel_modal.picker.clone())
+            .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
         });
 
         cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
@@ -143,7 +143,7 @@ impl View for ChannelModal {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = &theme::current(cx).collab_panel.channel_modal;
+        let theme = &theme::current(cx).collab_panel.tabbed_modal;
 
         let mode = self.picker.read(cx).delegate().mode;
         let Some(channel) = self
@@ -160,12 +160,12 @@ impl View for ChannelModal {
             mode: Mode,
             text: &'static str,
             current_mode: Mode,
-            theme: &theme::ChannelModal,
+            theme: &theme::TabbedModal,
             cx: &mut ViewContext<ChannelModal>,
         ) -> AnyElement<ChannelModal> {
             let active = mode == current_mode;
             MouseEventHandler::<T, _>::new(0, cx, move |state, _| {
-                let contained_text = theme.mode_button.style_for(active, state);
+                let contained_text = theme.tab_button.style_for(active, state);
                 Label::new(text, contained_text.text.clone())
                     .contained()
                     .with_style(contained_text.container.clone())
@@ -367,11 +367,17 @@ impl PickerDelegate for ChannelModalDelegate {
         selected: bool,
         cx: &gpui::AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let theme = &theme::current(cx).collab_panel.channel_modal;
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.channel_modal;
+        let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
         let (user, admin) = self.user_at_index(ix).unwrap();
         let request_status = self.member_status(user.id, cx);
 
-        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
+        let style = tabbed_modal
+            .picker
+            .item
+            .in_state(selected)
+            .style_for(mouse_state);
 
         let in_manage = matches!(self.mode, Mode::ManageMembers);
 
@@ -448,7 +454,7 @@ impl PickerDelegate for ChannelModalDelegate {
             .contained()
             .with_style(style.container)
             .constrained()
-            .with_height(theme.row_height)
+            .with_height(tabbed_modal.row_height)
             .into_any();
 
         if selected {

crates/collab_ui/src/collab_panel/contact_finder.rs 🔗

@@ -1,28 +1,127 @@
 use client::{ContactRequestStatus, User, UserStore};
-use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext};
+use gpui::{
+    elements::*, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
+};
 use picker::{Picker, PickerDelegate, PickerEvent};
 use std::sync::Arc;
 use util::TryFutureExt;
+use workspace::Modal;
 
 pub fn init(cx: &mut AppContext) {
     Picker::<ContactFinderDelegate>::init(cx);
 }
 
-pub type ContactFinder = Picker<ContactFinderDelegate>;
+pub struct ContactFinder {
+    picker: ViewHandle<Picker<ContactFinderDelegate>>,
+    has_focus: bool,
+}
 
-pub fn build_contact_finder(
-    user_store: ModelHandle<UserStore>,
-    cx: &mut ViewContext<ContactFinder>,
-) -> ContactFinder {
-    Picker::new(
-        ContactFinderDelegate {
-            user_store,
-            potential_contacts: Arc::from([]),
-            selected_index: 0,
-        },
-        cx,
-    )
-    .with_theme(|theme| theme.picker.clone())
+impl ContactFinder {
+    pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
+        let picker = cx.add_view(|cx| {
+            Picker::new(
+                ContactFinderDelegate {
+                    user_store,
+                    potential_contacts: Arc::from([]),
+                    selected_index: 0,
+                },
+                cx,
+            )
+            .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
+        });
+
+        cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
+
+        Self {
+            picker,
+            has_focus: false,
+        }
+    }
+
+    pub fn set_query(&mut self, query: String, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.set_query(query, cx);
+        });
+    }
+}
+
+impl Entity for ContactFinder {
+    type Event = PickerEvent;
+}
+
+impl View for ContactFinder {
+    fn ui_name() -> &'static str {
+        "ContactFinder"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.tabbed_modal;
+
+        fn render_mode_button(
+            text: &'static str,
+            theme: &theme::TabbedModal,
+            _cx: &mut ViewContext<ContactFinder>,
+        ) -> AnyElement<ContactFinder> {
+            let contained_text = &theme.tab_button.active_state().default;
+            Label::new(text, contained_text.text.clone())
+                .contained()
+                .with_style(contained_text.container.clone())
+                .into_any()
+        }
+
+        Flex::column()
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new("Contacts", theme.title.text.clone())
+                            .contained()
+                            .with_style(theme.title.container.clone()),
+                    )
+                    .with_child(Flex::row().with_children([render_mode_button(
+                        "Invite new contacts",
+                        &theme,
+                        cx,
+                    )]))
+                    .expanded()
+                    .contained()
+                    .with_style(theme.header),
+            )
+            .with_child(
+                ChildView::new(&self.picker, cx)
+                    .contained()
+                    .with_style(theme.body),
+            )
+            .constrained()
+            .with_max_height(theme.max_height)
+            .with_max_width(theme.max_width)
+            .contained()
+            .with_style(theme.modal)
+            .into_any()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_focus = true;
+        if cx.is_self_focused() {
+            cx.focus(&self.picker)
+        }
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+}
+
+impl Modal for ContactFinder {
+    fn has_focus(&self) -> bool {
+        self.has_focus
+    }
+
+    fn dismiss_on_event(event: &Self::Event) -> bool {
+        match event {
+            PickerEvent::Dismiss => true,
+        }
+    }
 }
 
 pub struct ContactFinderDelegate {
@@ -97,7 +196,9 @@ impl PickerDelegate for ContactFinderDelegate {
         selected: bool,
         cx: &gpui::AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let theme = &theme::current(cx).contact_finder;
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.contact_finder;
+        let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
         let user = &self.potential_contacts[ix];
         let request_status = self.user_store.read(cx).contact_request_status(user);
 
@@ -113,7 +214,11 @@ impl PickerDelegate for ContactFinderDelegate {
         } else {
             &theme.contact_button
         };
-        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
+        let style = tabbed_modal
+            .picker
+            .item
+            .in_state(selected)
+            .style_for(mouse_state);
         Flex::row()
             .with_children(user.avatar.clone().map(|avatar| {
                 Image::from_data(avatar)
@@ -145,7 +250,7 @@ impl PickerDelegate for ContactFinderDelegate {
             .contained()
             .with_style(style.container)
             .constrained()
-            .with_height(theme.row_height)
+            .with_height(tabbed_modal.row_height)
             .into_any()
     }
 }

crates/theme/src/theme.rs 🔗

@@ -48,7 +48,6 @@ pub struct Theme {
     pub collab_panel: CollabPanel,
     pub project_panel: ProjectPanel,
     pub command_palette: CommandPalette,
-    pub contact_finder: ContactFinder,
     pub picker: Picker,
     pub editor: Editor,
     pub search: Search,
@@ -224,6 +223,8 @@ pub struct CollabPanel {
     pub log_in_button: Interactive<ContainedText>,
     pub channel_editor: ContainerStyle,
     pub channel_hash: Icon,
+    pub tabbed_modal: TabbedModal,
+    pub contact_finder: ContactFinder,
     pub channel_modal: ChannelModal,
     pub user_query_editor: FieldEditor,
     pub user_query_editor_height: f32,
@@ -251,13 +252,20 @@ pub struct CollabPanel {
 }
 
 #[derive(Deserialize, Default, JsonSchema)]
-pub struct ChannelModal {
-    pub max_height: f32,
-    pub max_width: f32,
+pub struct TabbedModal {
+    pub tab_button: Toggleable<Interactive<ContainedText>>,
+    pub modal: ContainerStyle,
+    pub header: ContainerStyle,
+    pub body: ContainerStyle,
     pub title: ContainedText,
-    pub mode_button: Toggleable<Interactive<ContainedText>>,
     pub picker: Picker,
+    pub max_height: f32,
+    pub max_width: f32,
     pub row_height: f32,
+}
+
+#[derive(Deserialize, Default, JsonSchema)]
+pub struct ChannelModal {
     pub contact_avatar: ImageStyle,
     pub contact_username: ContainerStyle,
     pub remove_member_button: ContainedText,
@@ -265,9 +273,6 @@ pub struct ChannelModal {
     pub member_icon: Icon,
     pub invitee_icon: Icon,
     pub member_tag: ContainedText,
-    pub modal: ContainerStyle,
-    pub header: ContainerStyle,
-    pub body: ContainerStyle,
 }
 
 #[derive(Deserialize, Default, JsonSchema)]
@@ -286,8 +291,6 @@ pub struct TreeBranch {
 
 #[derive(Deserialize, Default, JsonSchema)]
 pub struct ContactFinder {
-    pub picker: Picker,
-    pub row_height: f32,
     pub contact_avatar: ImageStyle,
     pub contact_username: ContainerStyle,
     pub contact_button: IconButton,

crates/vcs_menu/src/lib.rs 🔗

@@ -256,7 +256,7 @@ impl PickerDelegate for BranchListDelegate {
             .contained()
             .with_style(style.container)
             .constrained()
-            .with_height(theme.contact_finder.row_height)
+            .with_height(theme.collab_panel.tabbed_modal.row_height)
             .into_any()
     }
     fn render_header(

styles/src/style_tree/app.ts 🔗

@@ -46,7 +46,6 @@ export default function app(): any {
         project_diagnostics: project_diagnostics(),
         project_panel: project_panel(),
         collab_panel: collab_panel(),
-        contact_finder: contact_finder(),
         toolbar_dropdown_menu: toolbar_dropdown_menu(),
         search: search(),
         shared_screen: shared_screen(),

styles/src/style_tree/channel_modal.ts 🔗

@@ -1,153 +0,0 @@
-import { useTheme } from "../theme"
-import { background, border, foreground, text } from "./components"
-import picker from "./picker"
-import { input } from "../component/input"
-import { toggleable_text_button } from "../component/text_button"
-
-export default function channel_modal(): any {
-    const theme = useTheme()
-
-    const side_margin = 6
-    const contact_button = {
-        background: background(theme.middle, "variant"),
-        color: foreground(theme.middle, "variant"),
-        icon_width: 8,
-        button_width: 16,
-        corner_radius: 8,
-    }
-
-    const picker_style = picker()
-    delete picker_style.shadow
-    delete picker_style.border
-
-    const picker_input = input()
-
-    return {
-        header: {
-            background: background(theme.middle, "accent"),
-            border: border(theme.middle, { "bottom": true, "top": false, left: false, right: false }),
-            corner_radii: {
-                top_right: 12,
-                top_left: 12,
-            }
-        },
-        body: {
-            background: background(theme.middle),
-            corner_radii: {
-                bottom_right: 12,
-                bottom_left: 12,
-            }
-        },
-        modal: {
-            background: background(theme.middle),
-            shadow: theme.modal_shadow,
-            corner_radius: 12,
-            padding: {
-                bottom: 0,
-                left: 0,
-                right: 0,
-                top: 0,
-            },
-
-        },
-        // This is used for the icons that are rendered to the right of channel Members in both UIs
-        member_icon: {
-            background: background(theme.middle),
-            padding: {
-                bottom: 4,
-                left: 4,
-                right: 4,
-                top: 4,
-            },
-            width: 5,
-            color: foreground(theme.middle, "accent"),
-        },
-        // This is used for the icons that are rendered to the right of channel invites in both UIs
-        invitee_icon: {
-            background: background(theme.middle),
-            padding: {
-                bottom: 4,
-                left: 4,
-                right: 4,
-                top: 4,
-            },
-            width: 5,
-            color: foreground(theme.middle, "accent"),
-        },
-        remove_member_button: {
-            ...text(theme.middle, "sans", { size: "xs" }),
-            background: background(theme.middle),
-            padding: {
-                left: 7,
-                right: 7
-            }
-        },
-        cancel_invite_button: {
-            ...text(theme.middle, "sans", { size: "xs" }),
-            background: background(theme.middle),
-        },
-        member_tag: {
-            ...text(theme.middle, "sans", { size: "xs" }),
-            border: border(theme.middle, "active"),
-            background: background(theme.middle),
-            margin: {
-                left: 8,
-            },
-            padding: {
-                left: 4,
-                right: 4,
-            }
-        },
-        max_height: 400,
-        max_width: 540,
-        title: {
-            ...text(theme.middle, "sans", "on", { size: "lg" }),
-            padding: {
-                left: 6,
-            }
-        },
-        mode_button: toggleable_text_button(theme, {
-            variant: "ghost",
-            layer: theme.middle,
-            active_color: "accent",
-            margin: {
-                top: 8,
-                bottom: 8,
-                right: 4
-            }
-        }),
-        picker: {
-            empty_container: {},
-            item: {
-                ...picker_style.item,
-                margin: { left: side_margin, right: side_margin },
-            },
-            no_matches: picker_style.no_matches,
-            input_editor: picker_input,
-            empty_input_editor: picker_input,
-            header: picker_style.header,
-            footer: picker_style.footer,
-        },
-        row_height: 28,
-        contact_avatar: {
-            corner_radius: 10,
-            width: 18,
-        },
-        contact_username: {
-            padding: {
-                left: 8,
-            },
-        },
-        contact_button: {
-            ...contact_button,
-            hover: {
-                background: background(theme.middle, "variant", "hovered"),
-            },
-        },
-        disabled_contact_button: {
-            ...contact_button,
-            background: background(theme.middle, "disabled"),
-            color: foreground(theme.middle, "disabled"),
-        },
-    }
-}

styles/src/style_tree/collab_modals.ts 🔗

@@ -0,0 +1,159 @@
+import { useTheme } from "../theme"
+import { background, border, foreground, text } from "./components"
+import picker from "./picker"
+import { input } from "../component/input"
+import { toggleable_text_button } from "../component/text_button"
+import contact_finder from "./contact_finder"
+
+export default function channel_modal(): any {
+    const theme = useTheme()
+
+    const side_margin = 6
+    const contact_button = {
+        background: background(theme.middle, "variant"),
+        color: foreground(theme.middle, "variant"),
+        icon_width: 8,
+        button_width: 16,
+        corner_radius: 8,
+    }
+
+    const picker_style = picker()
+    delete picker_style.shadow
+    delete picker_style.border
+
+    const picker_input = input()
+
+    return {
+        contact_finder: contact_finder(),
+        tabbed_modal: {
+            tab_button: toggleable_text_button(theme, {
+                variant: "ghost",
+                layer: theme.middle,
+                active_color: "accent",
+                margin: {
+                    top: 8,
+                    bottom: 8,
+                    right: 4
+                }
+            }),
+            row_height: 28,
+            header: {
+                background: background(theme.middle, "accent"),
+                border: border(theme.middle, { "bottom": true, "top": false, left: false, right: false }),
+                corner_radii: {
+                    top_right: 12,
+                    top_left: 12,
+                }
+            },
+            body: {
+                background: background(theme.middle),
+                corner_radii: {
+                    bottom_right: 12,
+                    bottom_left: 12,
+                }
+            },
+            modal: {
+                background: background(theme.middle),
+                shadow: theme.modal_shadow,
+                corner_radius: 12,
+                padding: {
+                    bottom: 0,
+                    left: 0,
+                    right: 0,
+                    top: 0,
+                },
+
+            },
+            max_height: 400,
+            max_width: 540,
+            title: {
+                ...text(theme.middle, "sans", "on", { size: "lg" }),
+                padding: {
+                    left: 6,
+                }
+            },
+            picker: {
+                empty_container: {},
+                item: {
+                    ...picker_style.item,
+                    margin: { left: side_margin, right: side_margin },
+                },
+                no_matches: picker_style.no_matches,
+                input_editor: picker_input,
+                empty_input_editor: picker_input,
+                header: picker_style.header,
+                footer: picker_style.footer,
+            },
+        },
+        channel_modal: {
+            // This is used for the icons that are rendered to the right of channel Members in both UIs
+            member_icon: {
+                background: background(theme.middle),
+                padding: {
+                    bottom: 4,
+                    left: 4,
+                    right: 4,
+                    top: 4,
+                },
+                width: 5,
+                color: foreground(theme.middle, "accent"),
+            },
+            // This is used for the icons that are rendered to the right of channel invites in both UIs
+            invitee_icon: {
+                background: background(theme.middle),
+                padding: {
+                    bottom: 4,
+                    left: 4,
+                    right: 4,
+                    top: 4,
+                },
+                width: 5,
+                color: foreground(theme.middle, "accent"),
+            },
+            remove_member_button: {
+                ...text(theme.middle, "sans", { size: "xs" }),
+                background: background(theme.middle),
+                padding: {
+                    left: 7,
+                    right: 7
+                }
+            },
+            cancel_invite_button: {
+                ...text(theme.middle, "sans", { size: "xs" }),
+                background: background(theme.middle),
+            },
+            member_tag: {
+                ...text(theme.middle, "sans", { size: "xs" }),
+                border: border(theme.middle, "active"),
+                background: background(theme.middle),
+                margin: {
+                    left: 8,
+                },
+                padding: {
+                    left: 4,
+                    right: 4,
+                }
+            },
+            contact_avatar: {
+                corner_radius: 10,
+                width: 18,
+            },
+            contact_username: {
+                padding: {
+                    left: 8,
+                },
+            },
+            contact_button: {
+                ...contact_button,
+                hover: {
+                    background: background(theme.middle, "variant", "hovered"),
+                },
+            },
+            disabled_contact_button: {
+                ...contact_button,
+                background: background(theme.middle, "disabled"),
+                color: foreground(theme.middle, "disabled"),
+            },
+        }
+    }
+}

styles/src/style_tree/collab_panel.ts 🔗

@@ -7,9 +7,7 @@ import {
 } from "./components"
 import { interactive, toggleable } from "../element"
 import { useTheme } from "../theme"
-import channel_modal from "./channel_modal"
-import { icon_button, toggleable_icon_button } from "../component/icon_button"
-
+import collab_modals from "./collab_modals"
 
 export default function contacts_panel(): any {
     const theme = useTheme()
@@ -109,7 +107,7 @@ export default function contacts_panel(): any {
 
 
     return {
-        channel_modal: channel_modal(),
+        ...collab_modals(),
         log_in_button: interactive({
             base: {
                 background: background(theme.middle),

styles/src/style_tree/contact_finder.ts 🔗

@@ -1,11 +1,11 @@
-import picker from "./picker"
+// import picker from "./picker"
 import { background, border, foreground, text } from "./components"
 import { useTheme } from "../theme"
 
 export default function contact_finder(): any {
     const theme = useTheme()
 
-    const side_margin = 6
+    // const side_margin = 6
     const contact_button = {
         background: background(theme.middle, "variant"),
         color: foreground(theme.middle, "variant"),
@@ -14,42 +14,42 @@ export default function contact_finder(): any {
         corner_radius: 8,
     }
 
-    const picker_style = picker()
-    const picker_input = {
-        background: background(theme.middle, "on"),
-        corner_radius: 6,
-        text: text(theme.middle, "mono"),
-        placeholder_text: text(theme.middle, "mono", "on", "disabled", {
-            size: "xs",
-        }),
-        selection: theme.players[0],
-        border: border(theme.middle),
-        padding: {
-            bottom: 4,
-            left: 8,
-            right: 8,
-            top: 4,
-        },
-        margin: {
-            left: side_margin,
-            right: side_margin,
-        },
-    }
+    // const picker_style = picker()
+    // const picker_input = {
+    //     background: background(theme.middle, "on"),
+    //     corner_radius: 6,
+    //     text: text(theme.middle, "mono"),
+    //     placeholder_text: text(theme.middle, "mono", "on", "disabled", {
+    //         size: "xs",
+    //     }),
+    //     selection: theme.players[0],
+    //     border: border(theme.middle),
+    //     padding: {
+    //         bottom: 4,
+    //         left: 8,
+    //         right: 8,
+    //         top: 4,
+    //     },
+    //     margin: {
+    //         left: side_margin,
+    //         right: side_margin,
+    //     },
+    // }
 
     return {
-        picker: {
-            empty_container: {},
-            item: {
-                ...picker_style.item,
-                margin: { left: side_margin, right: side_margin },
-            },
-            no_matches: picker_style.no_matches,
-            input_editor: picker_input,
-            empty_input_editor: picker_input,
-            header: picker_style.header,
-            footer: picker_style.footer,
-        },
-        row_height: 28,
+        // picker: {
+        //     empty_container: {},
+        //     item: {
+        //         ...picker_style.item,
+        //         margin: { left: side_margin, right: side_margin },
+        //     },
+        //     no_matches: picker_style.no_matches,
+        //     input_editor: picker_input,
+        //     empty_input_editor: picker_input,
+        //     header: picker_style.header,
+        //     footer: picker_style.footer,
+        // },
+        // row_height: 28,
         contact_avatar: {
             corner_radius: 10,
             width: 18,