diff --git a/assets/icons/on_call.svg b/assets/icons/on_call.svg index 66e5d71304..2e86c5d660 100644 --- a/assets/icons/on_call.svg +++ b/assets/icons/on_call.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index f9baa1d475..30217a1e1f 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -14,10 +14,10 @@ use editor::{Editor, EditorElement, EditorStyle}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div, - Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, ListOffset, - ListState, MouseDownEvent, Pixels, Point, PromptLevel, SharedString, Subscription, Task, - TextStyle, WeakEntity, Window, actions, anchored, canvas, deferred, div, fill, list, point, - prelude::*, px, + Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, MouseButton, + MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, SharedString, Subscription, Task, + TextStyle, UniformListScrollHandle, WeakEntity, Window, actions, anchored, canvas, deferred, + div, fill, point, prelude::*, px, size, uniform_list, }; use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious}; @@ -30,13 +30,14 @@ use rpc::{ use serde::{Deserialize, Serialize}; use settings::Settings; use smallvec::SmallVec; -use std::{mem, sync::Arc, time::Duration}; +use std::{mem, ops::Range, sync::Arc, time::Duration}; use theme::ActiveTheme; use theme_settings::ThemeSettings; use ui::{ - Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, Facepile, - HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, TintColor, Tooltip, - prelude::*, tooltip_container, + Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, + DecoratedIcon, Disclosure, Facepile, HighlightedLabel, IconButtonShape, IconDecoration, + IconDecorationKind, IndentGuideColors, Indicator, ListHeader, ListItem, ScrollAxes, Scrollbars, + Tab, TintColor, Tooltip, WithScrollbar, prelude::*, tooltip_container, }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ @@ -51,6 +52,8 @@ use workspace::{ const FILTER_OCCUPIED_CHANNELS_KEY: &str = "filter_occupied_channels"; const FAVORITE_CHANNELS_KEY: &str = "favorite_channels"; +const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel"; +const TOAST_DURATION: Duration = Duration::from_secs(5); actions!( collab_panel, @@ -91,8 +94,10 @@ struct ChannelMoveClipboard { channel_id: ChannelId, } -const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel"; -const TOAST_DURATION: Duration = Duration::from_secs(5); +struct ContactContextMenu { + user_id: u64, + via_ellipsis_button: bool, +} pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { @@ -257,7 +262,8 @@ pub struct CollabPanel { pending_favorites_serialization: Task>, pending_filter_serialization: Task>, context_menu: Option<(Entity, Point, Subscription)>, - list_state: ListState, + contact_context_menu: Option, + scroll_handle: UniformListScrollHandle, filter_editor: Entity, channel_name_editor: Entity, channel_editing_state: Option, @@ -273,6 +279,7 @@ pub struct CollabPanel { collapsed_channels: Vec, filter_occupied_channels: bool, workspace: WeakEntity, + hovered_channel: Option<(ChannelId, bool)>, notification_store: Entity, current_notification_toast: Option<(u64, Task<()>)>, mark_as_read_tasks: HashMap>>, @@ -391,12 +398,14 @@ impl CollabPanel { let mut this = Self { focus_handle: cx.focus_handle(), channel_clipboard: None, + hovered_channel: None, fs: workspace.app_state().fs.clone(), pending_panel_serialization: Task::ready(None), pending_favorites_serialization: Task::ready(None), pending_filter_serialization: Task::ready(None), context_menu: None, - list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), + contact_context_menu: None, + scroll_handle: UniformListScrollHandle::new(), channel_name_editor, filter_editor, entries: Vec::default(), @@ -572,7 +581,8 @@ impl CollabPanel { } fn scroll_to_item(&mut self, ix: usize) { - self.list_state.scroll_to_reveal_item(ix) + self.scroll_handle + .scroll_to_item(ix, ScrollStrategy::Nearest) } fn update_entries(&mut self, select_same_item: bool, cx: &mut Context) { @@ -1068,54 +1078,75 @@ impl CollabPanel { }); } - let old_scroll_top = self.list_state.logical_scroll_top(); - self.list_state.reset(self.entries.len()); - if scroll_to_top { - self.list_state.scroll_to(ListOffset::default()); - } else { - // Attempt to maintain the same scroll position. - if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) { - let new_scroll_top = self + let state = self.scroll_handle.0.borrow(); + state.base_handle.set_offset(Point::default()); + } else if let Some(row_height) = self.row_height(old_entries.len()) { + // Attempt to maintain the same scroll position. Since every row has + // the same height, the scroll offset can be translated to and from a + // logical entry index plus a pixel remainder. + let old_scroll_y = -self.scroll_handle.0.borrow().base_handle.offset().y; + let old_top_ix = (old_scroll_y / row_height) as usize; + let offset_in_item = old_scroll_y - row_height * old_top_ix as f32; + + if let Some(old_top_entry) = old_entries.get(old_top_ix) { + let new_scroll_y = self .entries .iter() .position(|entry| entry == old_top_entry) - .map(|item_ix| ListOffset { - item_ix, - offset_in_item: old_scroll_top.offset_in_item, - }) + .map(|item_ix| row_height * item_ix as f32 + offset_in_item) .or_else(|| { - let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?; + let entry_after_old_top = old_entries.get(old_top_ix + 1)?; let item_ix = self .entries .iter() .position(|entry| entry == entry_after_old_top)?; - Some(ListOffset { - item_ix, - offset_in_item: Pixels::ZERO, - }) + Some(row_height * item_ix as f32) }) .or_else(|| { - let entry_before_old_top = - old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?; + let entry_before_old_top = old_entries.get(old_top_ix.saturating_sub(1))?; let item_ix = self .entries .iter() .position(|entry| entry == entry_before_old_top)?; - Some(ListOffset { - item_ix, - offset_in_item: Pixels::ZERO, - }) - }); + Some(row_height * item_ix as f32) + }) + .unwrap_or(old_scroll_y); - self.list_state - .scroll_to(new_scroll_top.unwrap_or(old_scroll_top)); + let state = self.scroll_handle.0.borrow(); + let mut offset = state.base_handle.offset(); + offset.y = -new_scroll_y; + state.base_handle.set_offset(offset); } } cx.notify(); } + fn row_height(&self, item_count: usize) -> Option { + if item_count == 0 { + return None; + } + let state = self.scroll_handle.0.borrow(); + let height = state.last_item_size?.contents.height / item_count as f32; + (height > px(0.)).then_some(height) + } + + fn bounds_for_item(&self, ix: usize) -> Option> { + let row_height = self.row_height(self.entries.len())?; + let state = self.scroll_handle.0.borrow(); + let list_bounds = state.base_handle.bounds(); + let origin = list_bounds.origin + + point( + px(0.), + state.base_handle.offset().y + row_height * ix as f32, + ); + Some(Bounds::new( + origin, + size(list_bounds.size.width, row_height), + )) + } + fn render_call_participant( &self, user: &Arc, @@ -1650,9 +1681,14 @@ impl CollabPanel { &mut self, position: Point, contact: Arc, + via_ellipsis_button: bool, window: &mut Window, cx: &mut Context, ) { + self.contact_context_menu = Some(ContactContextMenu { + user_id: contact.user.legacy_id, + via_ellipsis_button, + }); let this = cx.entity(); let in_room = ActiveCall::global(cx).read(cx).room().is_some(); @@ -1701,6 +1737,7 @@ impl CollabPanel { cx.focus_self(window); } this.context_menu.take(); + this.contact_context_menu.take(); cx.notify(); }, ); @@ -2354,10 +2391,7 @@ impl CollabPanel { window: &mut Window, cx: &mut Context, ) { - let Some(bounds) = self - .selection - .and_then(|ix| self.list_state.bounds_for_item(ix)) - else { + let Some(bounds) = self.selection.and_then(|ix| self.bounds_for_item(ix)) else { return; }; @@ -2374,7 +2408,7 @@ impl CollabPanel { }; if let Some(contact) = self.selected_contact() { - self.deploy_contact_context_menu(bounds.center(), contact, window, cx); + self.deploy_contact_context_menu(bounds.center(), contact, false, window, cx); cx.stop_propagation(); } } @@ -2729,13 +2763,14 @@ impl CollabPanel { channel, depth, has_children, + is_favorite, string_match, - .. } => self .render_channel( &channel, depth, has_children, + is_favorite, is_selected, ix, string_match.as_ref(), @@ -2781,7 +2816,7 @@ impl CollabPanel { } } - fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context) -> Div { + fn render_signed_in(&mut self, window: &mut Window, cx: &mut Context) -> Div { self.channel_store.update(cx, |channel_store, _| { channel_store.initialize(); }); @@ -2817,11 +2852,40 @@ impl CollabPanel { }), ) .child( - list( - self.list_state.clone(), - cx.processor(Self::render_list_entry), - ) - .size_full(), + div() + .size_full() + .child( + uniform_list( + "collab-panel-entries", + self.entries.len(), + cx.processor(|this, range: Range, window, cx| { + range + .map(|ix| this.render_list_entry(ix, window, cx)) + .collect() + }), + ) + .size_full() + .track_scroll(&self.scroll_handle) + .with_decoration( + ui::indent_guides(px(20.), IndentGuideColors::panel(cx)) + .with_left_offset(ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET) + .with_compute_indents_fn(cx.entity(), |this, range, _, _| { + range + .map(|ix| match this.entries.get(ix) { + Some(ListEntry::Channel { depth, .. }) + | Some(ListEntry::ChannelEditor { depth }) => *depth, + _ => 0, + }) + .collect() + }), + ), + ) + .custom_scrollbars( + Scrollbars::new(ScrollAxes::Vertical) + .tracked_scroll_handle(&self.scroll_handle), + window, + cx, + ), ) } @@ -2878,10 +2942,10 @@ impl CollabPanel { channel_link = Some(channel.link(cx)); (channel_icon, channel_tooltip_text) = match channel.visibility { proto::ChannelVisibility::Public => { - (Some("icons/public.svg"), Some("Copy public channel link.")) + (Some(IconName::Hash), Some("Copy Public Channel Link")) } proto::ChannelVisibility::Members => { - (Some("icons/hash.svg"), Some("Copy private channel link.")) + (Some(IconName::Lock), Some("Copy Private Channel Link")) } }; @@ -2958,7 +3022,7 @@ impl CollabPanel { .on_click( cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)), ) - .tooltip(Tooltip::text("Search for new contact")) + .tooltip(Tooltip::text("Search for New Contact")) .into_any_element(), ), Section::Channels => { @@ -3006,14 +3070,15 @@ impl CollabPanel { | Section::Offline => true, }; - h_flex().w_full().group("section-header").child( + h_flex().group("section-header").w_full().child( ListHeader::new(text) .when(can_collapse, |header| { - header.toggle(Some(!is_collapsed)).on_toggle(cx.listener( - move |this, _, _, cx| { + header + .toggle(Some(!is_collapsed)) + .disclosure_shape(IconButtonShape::Square) + .on_toggle(cx.listener(move |this, _, _, cx| { this.toggle_section_expanded(section, cx); - }, - )) + })) }) .inset(true) .end_slot::(button) @@ -3031,29 +3096,67 @@ impl CollabPanel { let online = contact.online; let busy = contact.busy || calling; let username = contact.user.username.clone(); + let open_context_menu = self + .contact_context_menu + .as_ref() + .filter(|menu| menu.user_id == contact.user.legacy_id); + let context_menu_open_via_button = + open_context_menu.is_some_and(|menu| menu.via_ellipsis_button); + + let context_menu_open_via_row = + open_context_menu.is_some_and(|menu| !menu.via_ellipsis_button); + let item = ListItem::new(username.clone()) .indent_level(1) .indent_step_size(px(20.)) - .toggle_state(is_selected) + .toggle_state(is_selected || context_menu_open_via_row) .child( h_flex() .w_full() .justify_between() - .child(render_participant_name_and_handle(&contact.user)) + .child( + h_flex() + .pl_2() + .gap_1p5() + .child( + Avatar::new(contact.user.avatar_uri.clone()) + .indicator::(if online { + let background = if is_selected || context_menu_open_via_row { + cx.theme().colors().ghost_element_selected + } else { + cx.theme().colors().panel_background + }; + Some( + AvatarAvailabilityIndicator::new(match busy { + true => ui::CollaboratorAvailability::Busy, + false => ui::CollaboratorAvailability::Free, + }) + .border_color(background), + ) + } else { + None + }), + ) + .child(render_participant_name_and_handle(&contact.user)), + ) .when(calling, |el| { - el.child(Label::new("Calling").color(Color::Muted)) + el.child(Label::new("Calling…").color(Color::Muted)) }) .when(!calling, |el| { el.child( IconButton::new("contact context menu", IconName::Ellipsis) .icon_color(Color::Muted) - .visible_on_hover("") + .toggle_state(context_menu_open_via_button) + .when(!context_menu_open_via_button, |this| { + this.visible_on_hover("") + }) .on_click(cx.listener({ let contact = contact.clone(); move |this, event: &ClickEvent, window, cx| { this.deploy_contact_context_menu( event.position(), contact.clone(), + true, window, cx, ); @@ -3065,40 +3168,36 @@ impl CollabPanel { .on_secondary_mouse_down(cx.listener({ let contact = contact.clone(); move |this, event: &MouseDownEvent, window, cx| { - this.deploy_contact_context_menu(event.position, contact.clone(), window, cx); + this.deploy_contact_context_menu( + event.position, + contact.clone(), + false, + window, + cx, + ); } - })) - .start_slot( - // todo handle contacts with no avatar - Avatar::new(contact.user.avatar_uri.clone()) - .indicator::(if online { - Some(AvatarAvailabilityIndicator::new(match busy { - true => ui::CollaboratorAvailability::Busy, - false => ui::CollaboratorAvailability::Free, - })) - } else { - None - }), - ); + })); div() .id(username.clone()) .group("") .child(item) - .tooltip(move |_, cx| { - let text = if !online { - format!(" {} is offline", &username) - } else if busy { - format!(" {} is on a call", &username) - } else { - let room = ActiveCall::global(cx).read(cx).room(); - if room.is_some() { - format!("Invite {} to join call", &username) + .when(open_context_menu.is_none(), |this| { + this.tooltip(move |_, cx| { + let text = if !online { + format!(" {} is Offline", &username) + } else if busy { + format!(" {} is on a Call", &username) } else { - format!("Call {}", &username) - } - }; - Tooltip::simple(text, cx) + let room = ActiveCall::global(cx).read(cx).room(); + if room.is_some() { + format!("Invite {} to Join Call", &username) + } else { + format!("Call {}", &username) + } + }; + Tooltip::simple(text, cx) + }) }) } @@ -3220,6 +3319,7 @@ impl CollabPanel { channel: &Channel, depth: usize, has_children: bool, + is_favorite_entry: bool, is_selected: bool, ix: usize, string_match: Option<&StringMatch>, @@ -3294,12 +3394,75 @@ impl CollabPanel { let height = rems_from_px(24.); + let icon_name = if is_public { + IconName::Hash + } else { + IconName::Lock + }; + + let icon_knockout_bg = if is_selected || is_active { + cx.theme().colors().ghost_element_selected + } else { + cx.theme().colors().panel_background + }; + + let is_hovered = self.hovered_channel == Some((channel_id, is_favorite_entry)); + + let icon_slot = h_flex().size_4().flex_none().justify_center().map(|slot| { + if has_children && is_hovered { + slot.child( + h_flex() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Disclosure::new("toggle", disclosed.unwrap_or(false)) + .shape(IconButtonShape::Square) + .on_click(cx.listener(move |this, _, window, cx| { + this.toggle_channel_collapsed(channel_id, window, cx) + })), + ), + ) + } else if has_notes_notification { + slot.child(DecoratedIcon::new( + Icon::new(icon_name) + .size(IconSize::Small) + .color(Color::Muted), + Some( + IconDecoration::new(IconDecorationKind::Dot, icon_knockout_bg, cx) + .color(cx.theme().colors().text_accent) + .position(Point { + x: px(-3.), + y: px(6.), + }), + ), + )) + } else { + slot.child( + Icon::new(icon_name) + .size(IconSize::Small) + .color(Color::Muted), + ) + } + }); + + let panel_bg = cx.theme().colors().panel_background; + let hover_bg = panel_bg.blend(cx.theme().colors().ghost_element_hover); + let active_bg = panel_bg.blend(cx.theme().colors().ghost_element_active); + h_flex() .id(ix) .group("") .h(height) .w_full() .overflow_hidden() + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + let hovered_channel = hovered.then_some((channel_id, is_favorite_entry)); + if this.hovered_channel != hovered_channel + && (*hovered || this.hovered_channel == Some((channel_id, is_favorite_entry))) + { + this.hovered_channel = hovered_channel; + cx.notify(); + } + })) .when(!channel.is_root_channel(), |el| { el.on_drag(channel.clone(), move |channel, _, _, cx| { cx.new(|_| DraggedChannelView { @@ -3328,14 +3491,9 @@ impl CollabPanel { .child( ListItem::new(ix) .height(height) - // Add one level of depth for the disclosure arrow. - .indent_level(depth + 1) + .indent_level(depth) .indent_step_size(px(20.)) .toggle_state(is_selected || is_active) - .toggle(disclosed) - .on_toggle(cx.listener(move |this, _, window, cx| { - this.toggle_channel_collapsed(channel_id, window, cx) - })) .on_click(cx.listener(move |this, _, window, cx| { if is_active { this.open_channel_notes(channel_id, window, cx) @@ -3359,27 +3517,8 @@ impl CollabPanel { .id(format!("inside-{}", channel_id.0)) .w_full() .gap_1() - .child( - div() - .relative() - .child( - Icon::new(if is_public { - IconName::Public - } else { - IconName::Hash - }) - .size(IconSize::Small) - .color(Color::Muted), - ) - .children(has_notes_notification.then(|| { - div() - .w_1p5() - .absolute() - .right(px(-1.)) - .top(px(-1.)) - .child(Indicator::dot().color(Color::Info)) - })), - ) + .pl_0p5() + .child(icon_slot) .child( h_flex() .id(channel_id.0 as usize) @@ -3408,14 +3547,16 @@ impl CollabPanel { ) .child( h_flex() + .id("channel-controls") .visible_on_hover("") .h_full() .absolute() .right_0() - .px_1() + .pl_1() + .pr_1p5() .gap_px() - .rounded_l_md() - .bg(cx.theme().colors().background) + .bg(hover_bg) + .group_active("", |s| s.bg(active_bg)) .child({ let focus_handle = self.focus_handle.clone(); IconButton::new("channel_favorite", favorite_icon) @@ -3460,8 +3601,7 @@ impl CollabPanel { ) -> impl IntoElement { let item = ListItem::new("channel-editor") .inset(false) - // Add one level of depth for the disclosure arrow. - .indent_level(depth + 1) + .indent_level(depth) .indent_step_size(px(20.)) .start_slot( Icon::new(IconName::Hash) @@ -3946,9 +4086,9 @@ impl Render for DraggedChannelView { .child( Icon::new( if self.channel.visibility == proto::ChannelVisibility::Public { - IconName::Public - } else { IconName::Hash + } else { + IconName::Lock }, ) .size(IconSize::Small) diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index 6d12a0d3d2..ff0240fed2 100644 --- a/crates/ui/src/components/avatar.rs +++ b/crates/ui/src/components/avatar.rs @@ -1,7 +1,7 @@ use crate::prelude::*; use documented::Documented; -use gpui::{AnyElement, Hsla, ImageSource, Img, IntoElement, Styled, img}; +use gpui::{AnyElement, AnyView, Hsla, ImageSource, Img, IntoElement, Styled, img}; /// An element that renders a user avatar with customizable appearance options. /// @@ -108,8 +108,6 @@ impl RenderOnce for Avatar { } } -use gpui::AnyView; - /// The audio status of an player, for use in representing /// their status visually on their avatar. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] @@ -191,6 +189,7 @@ pub enum CollaboratorAvailability { pub struct AvatarAvailabilityIndicator { availability: CollaboratorAvailability, avatar_size: Option, + border_color: Option, } impl AvatarAvailabilityIndicator { @@ -199,6 +198,7 @@ impl AvatarAvailabilityIndicator { Self { availability, avatar_size: None, + border_color: None, } } @@ -207,6 +207,11 @@ impl AvatarAvailabilityIndicator { self.avatar_size = size.into(); self } + + pub fn border_color(mut self, color: impl Into) -> Self { + self.border_color = Some(color.into()); + self + } } impl RenderOnce for AvatarAvailabilityIndicator { @@ -216,12 +221,22 @@ impl RenderOnce for AvatarAvailabilityIndicator { // HACK: non-integer sizes result in oval indicators. let indicator_size = (avatar_size * 0.4).round(); + let border_width = if self.border_color.is_some() { + px(2.) + } else { + px(0.) + }; + let container_size = indicator_size + border_width * 2.; + div() .absolute() - .bottom_0() - .right_0() - .size(indicator_size) - .rounded(indicator_size) + .bottom(-border_width) + .right(-border_width) + .size(container_size) + .rounded(container_size) + .when_some(self.border_color, |this, color| { + this.border(border_width).border_color(color) + }) .bg(match self.availability { CollaboratorAvailability::Free => cx.theme().status().created, CollaboratorAvailability::Busy => cx.theme().status().deleted, diff --git a/crates/ui/src/components/disclosure.rs b/crates/ui/src/components/disclosure.rs index 8cd1d5c0b9..de3d91bb14 100644 --- a/crates/ui/src/components/disclosure.rs +++ b/crates/ui/src/components/disclosure.rs @@ -1,7 +1,8 @@ use std::sync::Arc; -use gpui::{AnyView, ClickEvent, CursorStyle, SharedString}; +use gpui::{AnyView, ClickEvent, SharedString}; +use crate::IconButtonShape; use crate::prelude::*; #[derive(IntoElement, RegisterComponent)] @@ -10,12 +11,12 @@ pub struct Disclosure { is_open: bool, selected: bool, disabled: bool, - on_toggle_expanded: Option>, - cursor_style: CursorStyle, - opened_icon: IconName, closed_icon: IconName, + shape: Option, visible_on_hover: Option, tooltip: Option AnyView + 'static>>, + on_toggle_expanded: Option>, + opened_icon: IconName, } impl Disclosure { @@ -26,9 +27,9 @@ impl Disclosure { selected: false, disabled: false, on_toggle_expanded: None, - cursor_style: CursorStyle::PointingHand, opened_icon: IconName::ChevronDown, closed_icon: IconName::ChevronRight, + shape: None, visible_on_hover: None, tooltip: None, } @@ -61,6 +62,17 @@ impl Disclosure { self.disabled = disabled; self } + + /// Sets the shape of the underlying [`IconButton`]. + pub fn shape(mut self, shape: IconButtonShape) -> Self { + self.shape = Some(shape); + self + } + + /// Alias for [`Self::on_toggle_expanded`]. + pub fn on_click(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { + self.on_toggle_expanded(Arc::new(handler) as Arc<_>) + } } impl Toggleable for Disclosure { @@ -70,18 +82,6 @@ impl Toggleable for Disclosure { } } -impl Clickable for Disclosure { - fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { - self.on_toggle_expanded = Some(Arc::new(handler)); - self - } - - fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self { - self.cursor_style = cursor_style; - self - } -} - impl VisibleOnHover for Disclosure { fn visible_on_hover(mut self, group_name: impl Into) -> Self { self.visible_on_hover = Some(group_name.into()); @@ -103,6 +103,7 @@ impl RenderOnce for Disclosure { .aria_label(if self.is_open { "Collapse" } else { "Expand" }) .aria_expanded(self.is_open) .disabled(self.disabled) + .when_some(self.shape, |this, shape| this.shape(shape)) .toggle_state(self.selected) .when_some(self.visible_on_hover.clone(), |this, group_name| { this.visible_on_hover(group_name) diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index 741f60ba34..e844c72cb2 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::{Disclosure, prelude::*}; +use crate::{Disclosure, IconButtonShape, prelude::*}; use component::{Component, ComponentScope, example_group_with_title, single_example}; use gpui::{AnyElement, ClickEvent}; use theme::UiDensity; @@ -18,6 +18,7 @@ pub struct ListHeader { /// It will obscure the `end_slot` when visible. end_hover_slot: Option, toggle: Option, + disclosure_shape: Option, on_toggle: Option>, inset: bool, selected: bool, @@ -32,6 +33,7 @@ impl ListHeader { end_hover_slot: None, inset: false, toggle: None, + disclosure_shape: None, on_toggle: None, selected: false, } @@ -42,6 +44,12 @@ impl ListHeader { self } + /// Sets the shape of the disclosure button. + pub fn disclosure_shape(mut self, shape: IconButtonShape) -> Self { + self.disclosure_shape = Some(shape); + self + } + pub fn on_toggle( mut self, on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -108,6 +116,9 @@ impl RenderOnce for ListHeader { .gap(DynamicSpacing::Base04.rems(cx)) .children(self.toggle.map(|is_open| { Disclosure::new("toggle", is_open) + .when_some(self.disclosure_shape, |this, shape| { + this.shape(shape) + }) .on_toggle_expanded(self.on_toggle.clone()) })) .child(