diff --git a/crates/collab_ui2/src/collab_panel.rs b/crates/collab_ui2/src/collab_panel.rs index ac7457abe087954b918e6253428223837b0ca828..a34d574957e05ea2be5ffe6e446f2b588178d391 100644 --- a/crates/collab_ui2/src/collab_panel.rs +++ b/crates/collab_ui2/src/collab_panel.rs @@ -1156,7 +1156,7 @@ impl CollabPanel { let tooltip = format!("Follow {}", user.github_login); ListItem::new(SharedString::from(user.github_login.clone())) - .left_child(Avatar::new(user.avatar_uri.clone())) + .start_slot(Avatar::new(user.avatar_uri.clone())) .child( h_stack() .w_full() @@ -1212,7 +1212,7 @@ impl CollabPanel { .detach_and_log_err(cx); }); })) - .left_child(render_tree_branch(is_last, cx)) + .start_slot(render_tree_branch(is_last, cx)) .child(IconButton::new(0, Icon::Folder)) .child(Label::new(project_name.clone())) .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx)) @@ -1305,7 +1305,7 @@ impl CollabPanel { let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize); ListItem::new(("screen", id)) - .left_child(render_tree_branch(is_last, cx)) + .start_slot(render_tree_branch(is_last, cx)) .child(IconButton::new(0, Icon::Screen)) .child(Label::new("Screen")) .when_some(peer_id, |this, _| { @@ -1372,7 +1372,7 @@ impl CollabPanel { .on_click(cx.listener(move |this, _, cx| { this.open_channel_notes(channel_id, cx); })) - .left_child(render_tree_branch(false, cx)) + .start_slot(render_tree_branch(false, cx)) .child(IconButton::new(0, Icon::File)) .child(Label::new("notes")) .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx)) @@ -1387,7 +1387,7 @@ impl CollabPanel { .on_click(cx.listener(move |this, _, cx| { this.join_channel_chat(channel_id, cx); })) - .left_child(render_tree_branch(true, cx)) + .start_slot(render_tree_branch(true, cx)) .child(IconButton::new(0, Icon::MessageBubbles)) .child(Label::new("chat")) .tooltip(move |cx| Tooltip::text("Open Chat", cx)) @@ -2318,7 +2318,7 @@ impl CollabPanel { } else { el.child( ListHeader::new(text) - .when_some(button, |el, button| el.meta(button)) + .when_some(button, |el, button| el.end_slot(button)) .selected(is_selected), ) } @@ -2381,7 +2381,7 @@ impl CollabPanel { ) }), ) - .left_child( + .start_slot( // todo!() handle contacts with no avatar Avatar::new(contact.user.avatar_uri.clone()) .availability_indicator(if online { Some(!busy) } else { None }), @@ -2460,7 +2460,7 @@ impl CollabPanel { .child(Label::new(github_login.clone())) .child(h_stack().children(controls)), ) - .left_avatar(user.avatar_uri.clone()) + .start_slot::(user.avatar_uri.clone().map(|avatar| Avatar::new(avatar))) } fn render_contact_placeholder( @@ -2568,7 +2568,11 @@ impl CollabPanel { ListItem::new(channel_id as usize) .indent_level(depth) .indent_step_size(cx.rem_size() * 14.0 / 16.0) // @todo()! @nate this is to step over the disclosure toggle - .left_icon(if is_public { Icon::Public } else { Icon::Hash }) + .start_slot( + IconElement::new(if is_public { Icon::Public } else { Icon::Hash }) + .size(IconSize::Small) + .color(Color::Muted), + ) .selected(is_selected || is_active) .child( h_stack() @@ -2962,7 +2966,11 @@ impl CollabPanel { let item = ListItem::new("channel-editor") .inset(false) .indent_level(depth) - .left_icon(Icon::Hash); + .start_slot( + IconElement::new(Icon::Hash) + .size(IconSize::Small) + .color(Color::Muted), + ); if let Some(pending_name) = self .channel_editing_state diff --git a/crates/picker2/src/picker2.rs b/crates/picker2/src/picker2.rs index db5eebff5389a1f3bd9f0561a017fcb109ec904e..8d80f4b36c2d22783c4cd8921f0c21dc0b0d8470 100644 --- a/crates/picker2/src/picker2.rs +++ b/crates/picker2/src/picker2.rs @@ -271,7 +271,6 @@ impl Render for Picker { }, ) .track_scroll(self.scroll_handle.clone()) - .p_1() ) .max_h_72() .overflow_hidden(), diff --git a/crates/ui2/src/components/context_menu.rs b/crates/ui2/src/components/context_menu.rs index 3e542985147ddb99069f17b4922d0eb99fa2956e..250272b19882f031ab7af438bf3cfea7906bcec8 100644 --- a/crates/ui2/src/components/context_menu.rs +++ b/crates/ui2/src/components/context_menu.rs @@ -255,6 +255,9 @@ impl Render for ContextMenu { }; ListItem::new(label.clone()) + .inset(true) + .selected(Some(ix) == self.selected_index) + .on_click(move |_, cx| handler(cx)) .child( h_stack() .w_full() @@ -265,8 +268,6 @@ impl Render for ContextMenu { .map(|binding| div().ml_1().child(binding)) })), ) - .selected(Some(ix) == self.selected_index) - .on_click(move |_, cx| handler(cx)) .into_any_element() } }, diff --git a/crates/ui2/src/components/list/list_header.rs b/crates/ui2/src/components/list/list_header.rs index 933a1a95d7f00712e3fe640c4c8ebd597141978c..6c497752aeff5f58f2e60d209737fb0bd8d823b4 100644 --- a/crates/ui2/src/components/list/list_header.rs +++ b/crates/ui2/src/components/list/list_header.rs @@ -1,12 +1,18 @@ -use crate::{h_stack, prelude::*, Disclosure, Icon, IconElement, IconSize, Label}; +use crate::{h_stack, prelude::*, Disclosure, Label}; use gpui::{AnyElement, ClickEvent, Div}; -use smallvec::SmallVec; #[derive(IntoElement)] pub struct ListHeader { + /// The label of the header. label: SharedString, - left_icon: Option, - meta: SmallVec<[AnyElement; 2]>, + /// A slot for content that appears before the label, like an icon or avatar. + start_slot: Option, + /// A slot for content that appears after the label, usually on the other side of the header. + /// This might be a button, a disclosure arrow, a face pile, etc. + end_slot: Option, + /// A slot for content that appears on hover after the label + /// It will obscure the `end_slot` when visible. + end_hover_slot: Option, toggle: Option, on_toggle: Option>, inset: bool, @@ -17,8 +23,9 @@ impl ListHeader { pub fn new(label: impl Into) -> Self { Self { label: label.into(), - left_icon: None, - meta: SmallVec::new(), + start_slot: None, + end_slot: None, + end_hover_slot: None, inset: false, toggle: None, on_toggle: None, @@ -39,13 +46,23 @@ impl ListHeader { self } - pub fn left_icon(mut self, left_icon: impl Into>) -> Self { - self.left_icon = left_icon.into(); + pub fn start_slot(mut self, start_slot: impl Into>) -> Self { + self.start_slot = start_slot.into().map(IntoElement::into_any_element); self } - pub fn meta(mut self, meta: impl IntoElement) -> Self { - self.meta.push(meta.into_any_element()); + pub fn end_slot(mut self, end_slot: impl Into>) -> Self { + self.end_slot = end_slot.into().map(IntoElement::into_any_element); + self + } + + pub fn end_hover_slot(mut self, end_hover_slot: impl Into>) -> Self { + self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element); + self + } + + pub fn inset(mut self, inset: bool) -> Self { + self.inset = inset; self } } @@ -61,9 +78,9 @@ impl RenderOnce for ListHeader { type Rendered = Div; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - h_stack().w_full().relative().child( + h_stack().w_full().relative().group("list_header").child( div() - .h_5() + .h_7() .when(self.inset, |this| this.px_2()) .when(self.selected, |this| { this.bg(cx.theme().colors().ghost_element_selected) @@ -77,24 +94,30 @@ impl RenderOnce for ListHeader { .child( h_stack() .gap_1() + .children( + self.toggle + .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)), + ) .child( div() .flex() .gap_1() .items_center() - .children(self.left_icon.map(|i| { - IconElement::new(i) - .color(Color::Muted) - .size(IconSize::Small) - })) + .children(self.start_slot) .child(Label::new(self.label.clone()).color(Color::Muted)), - ) - .children( - self.toggle - .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)), ), ) - .child(h_stack().gap_2().items_center().children(self.meta)), + .child(h_stack().children(self.end_slot)) + .when_some(self.end_hover_slot, |this, end_hover_slot| { + this.child( + div() + .invisible() + .group_hover("list_header", |this| this.visible()) + .absolute() + .right_0() + .child(end_hover_slot), + ) + }), ) } } diff --git a/crates/ui2/src/components/list/list_item.rs b/crates/ui2/src/components/list/list_item.rs index 28a8b8cecbf159cdb02ef44a4e2ea74ae14ca71d..df6e542816eaba0526f3551729e42cc50ce846be 100644 --- a/crates/ui2/src/components/list/list_item.rs +++ b/crates/ui2/src/components/list/list_item.rs @@ -1,7 +1,6 @@ -use crate::{prelude::*, Avatar, Disclosure, Icon, IconElement, IconSize}; +use crate::{prelude::*, Disclosure}; use gpui::{ - px, AnyElement, AnyView, ClickEvent, Div, ImageSource, MouseButton, MouseDownEvent, Pixels, - Stateful, + px, AnyElement, AnyView, ClickEvent, Div, MouseButton, MouseDownEvent, Pixels, Stateful, }; use smallvec::SmallVec; @@ -9,11 +8,16 @@ use smallvec::SmallVec; pub struct ListItem { id: ElementId, selected: bool, - // TODO: Reintroduce this - // disclosure_control_style: DisclosureControlVisibility, indent_level: usize, indent_step_size: Pixels, - left_slot: Option, + /// A slot for content that appears before the children, like an icon or avatar. + start_slot: Option, + /// A slot for content that appears after the children, usually on the other side of the header. + /// This might be a button, a disclosure arrow, a face pile, etc. + end_slot: Option, + /// A slot for content that appears on hover after the children + /// It will obscure the `end_slot` when visible. + end_hover_slot: Option, toggle: Option, inset: bool, on_click: Option>, @@ -30,7 +34,9 @@ impl ListItem { selected: false, indent_level: 0, indent_step_size: px(12.), - left_slot: None, + start_slot: None, + end_slot: None, + end_hover_slot: None, toggle: None, inset: false, on_click: None, @@ -87,23 +93,18 @@ impl ListItem { self } - pub fn left_child(mut self, left_content: impl IntoElement) -> Self { - self.left_slot = Some(left_content.into_any_element()); + pub fn start_slot(mut self, start_slot: impl Into>) -> Self { + self.start_slot = start_slot.into().map(IntoElement::into_any_element); self } - pub fn left_icon(mut self, left_icon: Icon) -> Self { - self.left_slot = Some( - IconElement::new(left_icon) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element(), - ); + pub fn end_slot(mut self, end_slot: impl Into>) -> Self { + self.end_slot = end_slot.into().map(IntoElement::into_any_element); self } - pub fn left_avatar(mut self, left_avatar: impl Into) -> Self { - self.left_slot = Some(Avatar::new(left_avatar).into_any_element()); + pub fn end_hover_slot(mut self, end_hover_slot: impl Into>) -> Self { + self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element); self } } @@ -125,49 +126,105 @@ impl RenderOnce for ListItem { type Rendered = Stateful
; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - div() - .id(self.id) + h_stack() + .id("item_container") + .w_full() .relative() - // TODO: Add focus state - // .when(self.state == InteractionState::Focused, |this| { - // this.border() - // .border_color(cx.theme().colors().border_focused) - // }) - .when(self.inset, |this| this.rounded_md()) - .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) - .active(|style| style.bg(cx.theme().colors().ghost_element_active)) - .when(self.selected, |this| { - this.bg(cx.theme().colors().ghost_element_selected) + // When an item is inset draw the indent spacing outside of the item + .when(self.inset, |this| { + this.ml(self.indent_level as f32 * self.indent_step_size) + .px_1() }) - .when_some(self.on_click, |this, on_click| { - this.cursor_pointer().on_click(move |event, cx| { - // HACK: GPUI currently fires `on_click` with any mouse button, - // but we only care about the left button. - if event.down.button == MouseButton::Left { - (on_click)(event, cx) - } - }) + .when(!self.inset, |this| { + this + // TODO: Add focus state + // .when(self.state == InteractionState::Focused, |this| { + // this.border() + // .border_color(cx.theme().colors().border_focused) + // }) + .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .when(self.selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) }) - .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| { - this.on_mouse_down(MouseButton::Right, move |event, cx| { - (on_mouse_down)(event, cx) - }) - }) - .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) .child( - div() - .when(self.inset, |this| this.px_2()) - .ml(self.indent_level as f32 * self.indent_step_size) - .flex() - .gap_1() - .items_center() + h_stack() + .id(self.id) + .w_full() .relative() + .gap_1() + .px_2() + .group("list_item") + .when(self.inset, |this| { + this + // TODO: Add focus state + // .when(self.state == InteractionState::Focused, |this| { + // this.border() + // .border_color(cx.theme().colors().border_focused) + // }) + .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .when(self.selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) + }) + .when_some(self.on_click, |this, on_click| { + this.cursor_pointer().on_click(move |event, cx| { + // HACK: GPUI currently fires `on_click` with any mouse button, + // but we only care about the left button. + if event.down.button == MouseButton::Left { + (on_click)(event, cx) + } + }) + }) + .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| { + this.on_mouse_down(MouseButton::Right, move |event, cx| { + (on_mouse_down)(event, cx) + }) + }) + .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) + .map(|this| { + if self.inset { + this.rounded_md() + } else { + // When an item is not inset draw the indent spacing inside of the item + this.ml(self.indent_level as f32 * self.indent_step_size) + } + }) .children( self.toggle .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)), ) - .children(self.left_slot) - .children(self.children), + .child( + h_stack() + .flex_1() + .gap_1() + .children(self.start_slot) + .children(self.children), + ) + .when_some(self.end_slot, |this, end_slot| { + this.justify_between().child( + h_stack() + .when(self.end_hover_slot.is_some(), |this| { + this.visible() + .group_hover("list_item", |this| this.invisible()) + }) + .child(end_slot), + ) + }) + .when_some(self.end_hover_slot, |this, end_hover_slot| { + this.child( + h_stack() + .h_full() + .absolute() + .right_2() + .top_0() + .invisible() + .group_hover("list_item", |this| this.visible()) + .child(end_hover_slot), + ) + }), ) } } diff --git a/crates/ui2/src/components/list/list_separator.rs b/crates/ui2/src/components/list/list_separator.rs index 0398a110e977f94dd75bf458f7ef879c7a1a3cfd..346b13ddaa3d7f672813724022a323f29c6b720a 100644 --- a/crates/ui2/src/components/list/list_separator.rs +++ b/crates/ui2/src/components/list/list_separator.rs @@ -9,6 +9,10 @@ impl RenderOnce for ListSeparator { type Rendered = Div; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - div().h_px().w_full().bg(cx.theme().colors().border_variant) + div() + .h_px() + .w_full() + .my_1() + .bg(cx.theme().colors().border_variant) } } diff --git a/crates/ui2/src/components/list/list_sub_header.rs b/crates/ui2/src/components/list/list_sub_header.rs index 17f07b7b0bf90712dc9b8703c7f51261f5e5c49f..07a99dabe5bbb29bd25bde471948b709350123dc 100644 --- a/crates/ui2/src/components/list/list_sub_header.rs +++ b/crates/ui2/src/components/list/list_sub_header.rs @@ -6,7 +6,7 @@ use crate::{h_stack, Icon, IconElement, IconSize, Label}; #[derive(IntoElement)] pub struct ListSubHeader { label: SharedString, - left_icon: Option, + start_slot: Option, inset: bool, } @@ -14,13 +14,13 @@ impl ListSubHeader { pub fn new(label: impl Into) -> Self { Self { label: label.into(), - left_icon: None, + start_slot: None, inset: false, } } pub fn left_icon(mut self, left_icon: Option) -> Self { - self.left_icon = left_icon; + self.start_slot = left_icon; self } } @@ -44,7 +44,7 @@ impl RenderOnce for ListSubHeader { .flex() .gap_1() .items_center() - .children(self.left_icon.map(|i| { + .children(self.start_slot.map(|i| { IconElement::new(i) .color(Color::Muted) .size(IconSize::Small) diff --git a/crates/ui2/src/components/stories/list_header.rs b/crates/ui2/src/components/stories/list_header.rs index 056eaa276210c09cfbda303bf682db02562854cf..3c80afdde382be760b213531628a6774b43c5302 100644 --- a/crates/ui2/src/components/stories/list_header.rs +++ b/crates/ui2/src/components/stories/list_header.rs @@ -15,19 +15,19 @@ impl Render for ListHeaderStory { .child(Story::label("Default")) .child(ListHeader::new("Section 1")) .child(Story::label("With left icon")) - .child(ListHeader::new("Section 2").left_icon(Icon::Bell)) + .child(ListHeader::new("Section 2").start_slot(IconElement::new(Icon::Bell))) .child(Story::label("With left icon and meta")) .child( ListHeader::new("Section 3") - .left_icon(Icon::BellOff) - .meta(IconButton::new("action_1", Icon::Bolt)), + .start_slot(IconElement::new(Icon::BellOff)) + .end_slot(IconButton::new("action_1", Icon::Bolt)), ) .child(Story::label("With multiple meta")) .child( ListHeader::new("Section 4") - .meta(IconButton::new("action_1", Icon::Bolt)) - .meta(IconButton::new("action_2", Icon::ExclamationTriangle)) - .meta(IconButton::new("action_3", Icon::Plus)), + .end_slot(IconButton::new("action_1", Icon::Bolt)) + .end_slot(IconButton::new("action_2", Icon::ExclamationTriangle)) + .end_slot(IconButton::new("action_3", Icon::Plus)), ) } } diff --git a/crates/ui2/src/components/stories/list_item.rs b/crates/ui2/src/components/stories/list_item.rs index 91e95348fd936e08060eaf4a24989f4caef56462..fbcea44b579508a15b99dd746144e572e7c50de8 100644 --- a/crates/ui2/src/components/stories/list_item.rs +++ b/crates/ui2/src/components/stories/list_item.rs @@ -1,7 +1,7 @@ use gpui::{Div, Render}; use story::Story; -use crate::prelude::*; +use crate::{prelude::*, Avatar}; use crate::{Icon, ListItem}; pub struct ListItemStory; @@ -9,24 +9,80 @@ pub struct ListItemStory; impl Render for ListItemStory { type Element = Div; - fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { Story::container() + .bg(cx.theme().colors().background) .child(Story::title_for::()) .child(Story::label("Default")) .child(ListItem::new("hello_world").child("Hello, world!")) - .child(Story::label("With left icon")) + .child(Story::label("Inset")) .child( - ListItem::new("with_left_icon") + ListItem::new("hello_world") + .inset(true) + .start_slot( + IconElement::new(Icon::Bell) + .size(IconSize::Small) + .color(Color::Muted), + ) .child("Hello, world!") - .left_icon(Icon::Bell), + .end_slot( + IconElement::new(Icon::Bell) + .size(IconSize::Small) + .color(Color::Muted), + ), ) - .child(Story::label("With left avatar")) + .child(Story::label("With start slot icon")) + .child( + ListItem::new("with start slot_icon") + .child("Hello, world!") + .start_slot( + IconElement::new(Icon::Bell) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + .child(Story::label("With start slot avatar")) + .child( + ListItem::new("with_start slot avatar") + .child("Hello, world!") + .start_slot(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1714999?v=4", + ))), + ) + .child(Story::label("With end slot")) + .child( + ListItem::new("with_left_avatar") + .child("Hello, world!") + .end_slot(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1714999?v=4", + ))), + ) + .child(Story::label("With end hover slot")) .child( ListItem::new("with_left_avatar") .child("Hello, world!") - .left_avatar(SharedString::from( + .end_slot( + h_stack() + .gap_2() + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))) + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))) + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))) + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))) + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))), + ) + .end_hover_slot(Avatar::new(SharedString::from( "https://avatars.githubusercontent.com/u/1714999?v=4", - )), + ))), ) .child(Story::label("With `on_click`")) .child( diff --git a/crates/ui2/src/styled_ext.rs b/crates/ui2/src/styled_ext.rs index ed81c2cd0a78a0f353ba66dcd563e445e9abdbbd..3358968c727c26872a6140b83a68b81043634c52 100644 --- a/crates/ui2/src/styled_ext.rs +++ b/crates/ui2/src/styled_ext.rs @@ -118,16 +118,26 @@ pub trait StyledExt: Styled + Sized { elevated(self, cx, ElevationIndex::ModalSurface) } + /// The theme's primary border color. + fn border_primary(self, cx: &mut WindowContext) -> Self { + self.border_color(cx.theme().colors().border) + } + + /// The theme's secondary or muted border color. + fn border_muted(self, cx: &mut WindowContext) -> Self { + self.border_color(cx.theme().colors().border_variant) + } + fn debug_bg_red(self) -> Self { - self.bg(gpui::red()) + self.bg(hsla(0. / 360., 1., 0.5, 1.)) } fn debug_bg_green(self) -> Self { - self.bg(gpui::green()) + self.bg(hsla(120. / 360., 1., 0.5, 1.)) } fn debug_bg_blue(self) -> Self { - self.bg(gpui::blue()) + self.bg(hsla(240. / 360., 1., 0.5, 1.)) } fn debug_bg_yellow(self) -> Self {