use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::pane_group::{PaneHeaderAction, PaneHeaderCustomAction}; use crate::terminal::view::TerminalAction; use crate::{ appearance::Appearance, ui_components::{buttons::icon_button, icons::Icon}, }; use instant::Duration; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use session_sharing_protocol::common::{ParticipantId, ParticipantInfo, Role}; use session_sharing_protocol::sharer::RoleUpdateReason; use warpui::r#async::{SpawnedFutureHandle, Timer}; use warpui::{ accessibility::AccessibilityContent, elements::{ Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex, Hoverable, MainAxisAlignment, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, }, platform::Cursor, ui_components::components::{UiComponent, UiComponentStyles}, AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, }; use warpui::{FocusContext, ViewHandle}; use super::render_util::non_hoverable_participant_avatar; #[derive(Debug, Clone)] pub enum HoveredElement { Avatar, ContextMenu, } #[derive(Debug, Clone)] pub enum ParticipantAvatarAction { ScrollToSharedSessionParticipant { participant_id: ParticipantId, }, UpdateRole { participant_id: ParticipantId, role: Role, }, OpenTooltip, CloseTooltip, /// Opens the context menu on hover HoveredIn(HoveredElement), /// Closes context menu only if both elements /// have been hovered out of HoveredOut(HoveredElement), } pub enum ParticipantAvatarEvent { ScrollToSharedSessionParticipant { participant_id: ParticipantId, }, UpdateRole { participant_id: ParticipantId, role: Role, }, MenuOpened { participant_id: ParticipantId, }, MenuClosed, } pub struct ParticipantAvatarView { // Field from role of [`PresenceManager`] // Indicates whether we ourselves are the sharer is_manager_sharer: bool, // Fields from [`Participant`] needed for rendering participant_id: ParticipantId, display_name: String, image_url: Option, participant_color: ColorU, is_muted: bool, role: Option, // Mouse state to handle hover and click on avatar mouse_state_handle: MouseStateHandle, // Context menu fields menu: ViewHandle>, is_menu_open: bool, is_menu_hovered: bool, is_avatar_hovered: bool, menu_mouse_state_handle: MouseStateHandle, close_menu_abort_handle: Option, // Avatar context menu shouldn't trigger // while the pane header overflow menu is open is_pane_header_overflow_menu_open: bool, } impl ParticipantAvatarView { pub fn new( is_manager_sharer: bool, info: ParticipantInfo, participant_color: ColorU, is_muted: bool, role: Option, ctx: &mut ViewContext, ) -> Self { let menu = ctx.add_typed_action_view(|_| Menu::new().with_width(170.)); ctx.subscribe_to_view(&menu, move |me, _, event, ctx| { me.handle_menu_event(event, ctx); }); Self { is_manager_sharer, participant_id: info.id, display_name: info.profile_data.display_name, image_url: info.profile_data.photo_url, participant_color, is_muted, role, mouse_state_handle: Default::default(), menu, is_menu_open: false, is_menu_hovered: false, is_avatar_hovered: false, menu_mouse_state_handle: Default::default(), close_menu_abort_handle: None, is_pane_header_overflow_menu_open: false, } } pub fn set_participant_id(&mut self, id: ParticipantId) { self.participant_id = id; } pub fn set_display_name(&mut self, name: String) { self.display_name = name; } pub fn set_image_url(&mut self, path: Option) { self.image_url = path; } pub fn set_participant_color(&mut self, color: ColorU) { self.participant_color = color; } pub fn set_is_muted(&mut self, is_muted: bool) { self.is_muted = is_muted; } pub fn set_role(&mut self, role: Option) { self.role = role; } pub fn set_is_pane_header_overflow_menu_open(&mut self, is_open: bool) { self.is_pane_header_overflow_menu_open = is_open; } pub fn is_menu_open(&self) -> bool { self.is_menu_open } fn context_menu_items(&self) -> Vec> { let participant_id = self.participant_id.clone(); let mut items = vec![MenuItemFields::new(self.display_name.clone()) .with_disabled(true) .into_item()]; match self.role { Some(Role::Reader) => items.extend([MenuItemFields::new("Make editor") .with_on_select_action(ParticipantAvatarAction::UpdateRole { participant_id, role: Role::Executor, }) .into_item()]), Some(Role::Executor) => items.extend([MenuItemFields::new("Make viewer") .with_on_select_action(ParticipantAvatarAction::UpdateRole { participant_id, role: Role::Reader, }) .into_item()]), // Sharer does not have context menu _ => {} } items } fn handle_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext) { if let MenuEvent::Close { .. } = event { self.close_context_menu(ctx); } } pub fn open_context_menu(&mut self, ctx: &mut ViewContext) { self.is_menu_open = true; self.menu.update(ctx, |menu, ctx| { let items = self.context_menu_items(); menu.set_items(items, ctx); }); ctx.notify(); } pub fn close_context_menu(&mut self, ctx: &mut ViewContext) { self.is_menu_open = false; ctx.notify(); } fn render_edit_icon(&self, appearance: &Appearance) -> Box { let background = appearance.theme().surface_3(); Container::new( ConstrainedBox::new( Icon::Edit .to_warpui_icon(appearance.theme().foreground()) .finish(), ) .with_height(8.) .with_width(8.) .finish(), ) .with_uniform_padding(2.) .with_background(background) .with_corner_radius(CornerRadius::with_all( warpui::elements::Radius::Percentage(50.), )) .finish() } /// Helper function to render avatar context menu. /// Handles events on hover. fn render_menu(&self) -> Box { let is_menu_hovered = self.is_menu_hovered; Hoverable::new(self.menu_mouse_state_handle.clone(), |_| { ChildView::new(&self.menu).finish() }) .on_hover(move |mouse_in, ctx, _, _| { if mouse_in & !is_menu_hovered { // Ensure menu isn't already being hovered over ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredIn( HoveredElement::ContextMenu, )); } else if !mouse_in { ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredOut( HoveredElement::ContextMenu, )); } }) .finish() } /// Helper function to render non-hoverable participant avatar. /// Specifically handles adding edit icon to participants with `Role::Executor`. fn render_participant_avatar( &self, appearance: &Appearance, app: &AppContext, ) -> Box { let is_executor = self.role.is_some_and(|r| r.can_execute()); let avatar = non_hoverable_participant_avatar( self.display_name.clone(), self.image_url.clone(), self.participant_color, self.is_muted, is_executor, app, ); let mut stack = Stack::new(); stack.add_positioned_child( avatar, OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::ParentBySize, ParentAnchor::TopLeft, ChildAnchor::TopLeft, ), ); if is_executor { let icon = self.render_edit_icon(appearance); stack.add_positioned_child( icon, OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::ParentBySize, ParentAnchor::BottomRight, ChildAnchor::BottomRight, ), ); } Container::new( ConstrainedBox::new(stack.finish()) .with_min_height(20.) .with_min_width(20.) .finish(), ) .with_vertical_padding(2.) .finish() } /// Helper function to render hoverable participant avatar. /// Handles hover and click events. fn render_hoverable_participant_avatar( &self, appearance: &Appearance, app: &AppContext, ) -> Box { let participant_id = self.participant_id.clone(); let is_manager_sharer = self.is_manager_sharer; let is_avatar_hovered = self.is_avatar_hovered; Hoverable::new(self.mouse_state_handle.clone(), |_| { self.render_participant_avatar(appearance, app) }) .with_cursor(Cursor::PointingHand) .on_hover(move |mouse_in, ctx, _, _| { match (mouse_in, is_manager_sharer) { (true, true) => { if !is_avatar_hovered { ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredIn( HoveredElement::Avatar, )) } } (true, false) => ctx.dispatch_typed_action(ParticipantAvatarAction::OpenTooltip), (false, true) => ctx.dispatch_typed_action(ParticipantAvatarAction::HoveredOut( HoveredElement::Avatar, )), (false, false) => ctx.dispatch_typed_action(ParticipantAvatarAction::CloseTooltip), }; }) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ParticipantAvatarAction::ScrollToSharedSessionParticipant { participant_id: participant_id.clone(), }); }) .finish() } pub fn role(&self) -> Option { self.role } } impl Entity for ParticipantAvatarView { type Event = ParticipantAvatarEvent; } impl View for ParticipantAvatarView { fn ui_name() -> &'static str { "ParticipantAvatar" } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { ctx.notify(); } } fn accessibility_contents(&self, _ctx: &AppContext) -> Option { // TO DO None } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let avatar_hoverable = self.render_hoverable_participant_avatar(appearance, app); let mut stack = Stack::new().with_child(avatar_hoverable); // Add tooltip if hovering over avatars as viewer if !self.is_manager_sharer && self.is_avatar_hovered { stack.add_positioned_overlay_child( render_tooltip(self.display_name.clone(), appearance), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopMiddle, ChildAnchor::BottomMiddle, ), ); // Render context menu if hovering over viewer avatar as a sharer } else if self.is_manager_sharer && self.is_menu_open && !self.is_pane_header_overflow_menu_open && self.role.is_some() { stack.add_positioned_overlay_child( self.render_menu(), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::BottomRight, ChildAnchor::TopRight, ), ); } stack.finish() } } impl TypedActionView for ParticipantAvatarView { type Action = ParticipantAvatarAction; fn handle_action(&mut self, action: &ParticipantAvatarAction, ctx: &mut ViewContext) { match action { ParticipantAvatarAction::ScrollToSharedSessionParticipant { participant_id } => { ctx.emit(ParticipantAvatarEvent::ScrollToSharedSessionParticipant { participant_id: participant_id.clone(), }); } ParticipantAvatarAction::UpdateRole { participant_id, role, } => { ctx.emit(ParticipantAvatarEvent::UpdateRole { participant_id: participant_id.clone(), role: *role, }); } ParticipantAvatarAction::OpenTooltip => { self.is_avatar_hovered = true; } ParticipantAvatarAction::CloseTooltip => { self.is_avatar_hovered = false; } ParticipantAvatarAction::HoveredIn(menu_source) => { // Abort closing timer on open if let Some(old_abort_handle) = self.close_menu_abort_handle.take() { old_abort_handle.abort(); } // Update hover state match menu_source { HoveredElement::Avatar => self.is_avatar_hovered = true, HoveredElement::ContextMenu => self.is_menu_hovered = true, } self.open_context_menu(ctx); ctx.emit(ParticipantAvatarEvent::MenuOpened { participant_id: self.participant_id.clone(), }); } ParticipantAvatarAction::HoveredOut(menu_source) => { // Reset timer, if old one is still in progress if let Some(old_abort_handle) = self.close_menu_abort_handle.take() { old_abort_handle.abort(); } // Update hover state match menu_source { HoveredElement::Avatar => self.is_avatar_hovered = false, HoveredElement::ContextMenu => self.is_menu_hovered = false, } let should_close_menu = !self.is_avatar_hovered && !self.is_menu_hovered; // Add delay before closing if should_close_menu { let close_menu_abort_handle = ctx.spawn_abortable( Timer::after(Duration::from_millis(100)), move |me, _, ctx| { if should_close_menu { me.close_context_menu(ctx); ctx.emit(ParticipantAvatarEvent::MenuClosed); } }, |_, _| (), ); self.close_menu_abort_handle = Some(close_menu_abort_handle); } } } } } pub fn render_tooltip(label: String, appearance: &Appearance) -> Box { let tooltip_background = appearance.theme().tooltip_background(); appearance .ui_builder() .tool_tip(label) .with_style(UiComponentStyles { font_size: Some(12.), background: Some(Fill::Solid(tooltip_background)), font_color: Some(appearance.theme().background().into_solid()), ..Default::default() }) .build() .finish() } /// Helper function to render a button that revokes executor role from all viewers. pub fn render_revoke_all_button( mouse_state_handle: MouseStateHandle, appearance: &Appearance, ) -> Box { let edit = Icon::Edit .to_warpui_icon(appearance.theme().foreground()) .finish(); let slash = Icon::Slash .to_warpui_icon(appearance.theme().terminal_colors().normal.red.into()) .finish(); let mut stack = Stack::new().with_constrain_absolute_children(); stack.add_positioned_child( edit, OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::TopLeft, ), ); stack.add_positioned_child( slash, OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::TopLeft, ), ); Hoverable::new(mouse_state_handle, |state| { let mut button = Container::new( ConstrainedBox::new(stack.finish()) .with_width(16.) .with_height(16.) .finish(), ) .with_border(Border::all(1.)) .with_uniform_padding(4.) .with_margin_right(2.); let mut stack = Stack::new(); if state.is_hovered() { let background_color = if state.is_clicked() { appearance.theme().background().into() } else { appearance.theme().surface_2().into() }; button = button .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_background_color(background_color) .with_border( Border::all(1.).with_border_color(appearance.theme().surface_3().into()), ); stack.add_positioned_child( render_tooltip("Revoke all edit permissions".to_string(), appearance), OffsetPositioning::offset_from_parent( vec2f(0., 3.), ParentOffsetBounds::Unbounded, ParentAnchor::BottomMiddle, ChildAnchor::TopMiddle, ), ); } stack.add_child(button.finish()); stack.finish() }) .on_click(|ctx, _, _| { // We have to dispatch a pane header action because the button is rendered in the pane header. ctx.dispatch_typed_action(PaneHeaderCustomAction::( TerminalAction::MakeAllParticipantsReaders { reason: RoleUpdateReason::UpdatedBySharer, }, )); }) .finish() } /// Helper function to render a button that indicates a viewer's role. pub fn render_viewer_role_button( role: Option, mouse_state_handle: MouseStateHandle, menu_handle: Option>>>, is_menu_open: bool, appearance: &Appearance, ) -> Box { let icon = match role { Some(role) if role.can_execute() => Icon::Edit, _ => Icon::Eye, }; let ui_builder = appearance.ui_builder().clone(); let mut stack = Stack::new(); let button = icon_button(appearance, icon, false, mouse_state_handle.clone()) .with_tooltip(move || { ui_builder .tool_tip("Change role".to_string()) .build() .finish() }) .build() .on_click(|ctx, _, _| { // We have to dispatch a pane header action because the button is rendered in the pane header. ctx.dispatch_typed_action(PaneHeaderCustomAction::( TerminalAction::OpenSharedSessionViewerRoleMenu, )); }) .finish(); stack.add_child(button); if let Some(menu) = menu_handle { if is_menu_open { stack.add_positioned_overlay_child( ChildView::new(&menu).finish(), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::BottomRight, ChildAnchor::TopRight, ), ); } } Container::new(stack.finish()).with_margin_left(8.).finish() } /// Helper function to render participant avatar list and role buttons in the pane header. pub fn render_participants_and_role_elements( participants: Vec>, role: Option, mouse_state_handle: MouseStateHandle, menu_handle: Option>>>, is_menu_open: bool, hide_role_change_button: bool, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let mut row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center); // Only render button for sharer (client is sharer iff role is none) // when there exists viewers that are executors. let num_executors = participants .iter() .filter(|participant| { participant .as_ref(app) .role() .is_some_and(|r| r.can_execute()) }) .count(); if role.is_none() && num_executors > 0 { row.add_child(render_revoke_all_button( mouse_state_handle.clone(), appearance, )); } for participant in participants.iter() { row.add_child( Container::new(ChildView::new(participant).finish()) .with_horizontal_margin(1.) .finish(), ); } // Only render button for viewer, unless hide_role_change_button is true // (e.g., in cloud mode conversations where role changes are not supported) if role.is_some() && !hide_role_change_button { row.add_child(render_viewer_role_button( role, mouse_state_handle.clone(), menu_handle.clone(), is_menu_open, appearance, )); Container::new(row.finish()).finish() } else { row.finish() } }