1use crate::{
2 contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
3 toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute,
4 ToggleScreenSharing,
5};
6use call::{ActiveCall, ParticipantLocation, Room};
7use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore};
8use clock::ReplicaId;
9use contacts_popover::ContactsPopover;
10use context_menu::{ContextMenu, ContextMenuItem};
11use gpui::{
12 actions,
13 color::Color,
14 elements::*,
15 geometry::{rect::RectF, vector::vec2f, PathBuilder},
16 json::{self, ToJson},
17 platform::{CursorStyle, MouseButton},
18 AppContext, Entity, ImageData, LayoutContext, ModelHandle, SceneBuilder, Subscription, View,
19 ViewContext, ViewHandle, WeakViewHandle,
20};
21use project::{Project, RepositoryEntry};
22use std::{ops::Range, sync::Arc};
23use theme::{AvatarStyle, Theme};
24use util::ResultExt;
25use workspace::{FollowNextCollaborator, Workspace};
26
27// const MAX_TITLE_LENGTH: usize = 75;
28
29actions!(
30 collab,
31 [
32 ToggleContactsMenu,
33 ToggleUserMenu,
34 ShareProject,
35 UnshareProject,
36 ]
37);
38
39pub fn init(cx: &mut AppContext) {
40 cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
41 cx.add_action(CollabTitlebarItem::share_project);
42 cx.add_action(CollabTitlebarItem::unshare_project);
43 cx.add_action(CollabTitlebarItem::toggle_user_menu);
44}
45
46pub struct CollabTitlebarItem {
47 project: ModelHandle<Project>,
48 user_store: ModelHandle<UserStore>,
49 client: Arc<Client>,
50 workspace: WeakViewHandle<Workspace>,
51 contacts_popover: Option<ViewHandle<ContactsPopover>>,
52 user_menu: ViewHandle<ContextMenu>,
53 _subscriptions: Vec<Subscription>,
54}
55
56impl Entity for CollabTitlebarItem {
57 type Event = ();
58}
59
60impl View for CollabTitlebarItem {
61 fn ui_name() -> &'static str {
62 "CollabTitlebarItem"
63 }
64
65 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
66 let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
67 workspace
68 } else {
69 return Empty::new().into_any();
70 };
71
72 let project = self.project.read(cx);
73 let theme = theme::current(cx).clone();
74 let mut left_container = Flex::row();
75 let mut right_container = Flex::row().align_children_center();
76
77 left_container.add_child(self.collect_title_root_names(&project, theme.clone(), cx));
78
79 let user = self.user_store.read(cx).current_user();
80 let peer_id = self.client.peer_id();
81 if let Some(((user, peer_id), room)) = user
82 .as_ref()
83 .zip(peer_id)
84 .zip(ActiveCall::global(cx).read(cx).room().cloned())
85 {
86 right_container
87 .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
88 right_container.add_child(self.render_leave_call(&theme, cx));
89 let muted = room.read(cx).is_muted();
90 let speaking = room.read(cx).is_speaking();
91 left_container.add_child(
92 self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx),
93 );
94 left_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx));
95 right_container.add_child(self.render_toggle_mute(&theme, &room, cx));
96 right_container.add_child(self.render_toggle_deafen(&theme, &room, cx));
97 right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
98 }
99
100 let status = workspace.read(cx).client().status();
101 let status = &*status.borrow();
102 if matches!(status, client::Status::Connected { .. }) {
103 right_container.add_child(self.render_toggle_contacts_button(&theme, cx));
104 let avatar = user.as_ref().and_then(|user| user.avatar.clone());
105 right_container.add_child(self.render_user_menu_button(&theme, avatar, cx));
106 } else {
107 right_container.add_children(self.render_connection_status(status, cx));
108 right_container.add_child(self.render_sign_in_button(&theme, cx));
109 right_container.add_child(self.render_user_menu_button(&theme, None, cx));
110 }
111
112 Stack::new()
113 .with_child(left_container)
114 .with_child(
115 Flex::row()
116 .with_child(
117 right_container.contained().with_background_color(
118 theme
119 .titlebar
120 .container
121 .background_color
122 .unwrap_or_else(|| Color::transparent_black()),
123 ),
124 )
125 .aligned()
126 .right(),
127 )
128 .into_any()
129 }
130}
131
132impl CollabTitlebarItem {
133 pub fn new(
134 workspace: &Workspace,
135 workspace_handle: &ViewHandle<Workspace>,
136 cx: &mut ViewContext<Self>,
137 ) -> Self {
138 let project = workspace.project().clone();
139 let user_store = workspace.app_state().user_store.clone();
140 let client = workspace.app_state().client.clone();
141 let active_call = ActiveCall::global(cx);
142 let mut subscriptions = Vec::new();
143 subscriptions.push(cx.observe(workspace_handle, |_, _, cx| cx.notify()));
144 subscriptions.push(cx.observe(&project, |_, _, cx| cx.notify()));
145 subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
146 subscriptions.push(cx.observe_window_activation(|this, active, cx| {
147 this.window_activation_changed(active, cx)
148 }));
149 subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
150 subscriptions.push(
151 cx.subscribe(&user_store, move |this, user_store, event, cx| {
152 if let Some(workspace) = this.workspace.upgrade(cx) {
153 workspace.update(cx, |workspace, cx| {
154 if let client::Event::Contact { user, kind } = event {
155 if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
156 workspace.show_notification(user.id as usize, cx, |cx| {
157 cx.add_view(|cx| {
158 ContactNotification::new(
159 user.clone(),
160 *kind,
161 user_store,
162 cx,
163 )
164 })
165 })
166 }
167 }
168 });
169 }
170 }),
171 );
172
173 Self {
174 workspace: workspace.weak_handle(),
175 project,
176 user_store,
177 client,
178 contacts_popover: None,
179 user_menu: cx.add_view(|cx| {
180 let view_id = cx.view_id();
181 let mut menu = ContextMenu::new(view_id, cx);
182 menu.set_position_mode(OverlayPositionMode::Local);
183 menu
184 }),
185 _subscriptions: subscriptions,
186 }
187 }
188
189 fn collect_title_root_names(
190 &self,
191 project: &Project,
192 theme: Arc<Theme>,
193 cx: &ViewContext<Self>,
194 ) -> AnyElement<Self> {
195 let mut names_and_branches = project.visible_worktrees(cx).map(|worktree| {
196 let worktree = worktree.read(cx);
197 (worktree.root_name(), worktree.root_git_entry())
198 });
199
200 let (name, entry) = names_and_branches.next().unwrap_or(("", None));
201 let branch_prepended = entry
202 .as_ref()
203 .and_then(RepositoryEntry::branch)
204 .map(|branch| format!("/{branch}"));
205 let text_style = theme.titlebar.title.clone();
206 let item_spacing = theme.titlebar.item_spacing;
207
208 let mut highlight = text_style.clone();
209 highlight.color = theme.titlebar.highlight_color;
210
211 let style = LabelStyle {
212 text: text_style,
213 highlight_text: Some(highlight),
214 };
215 let mut ret = Flex::row().with_child(
216 Label::new(name.to_owned(), style.clone())
217 .with_highlights((0..name.len()).into_iter().collect())
218 .contained()
219 .aligned()
220 .left()
221 .into_any_named("title-project-name"),
222 );
223 if let Some(git_branch) = branch_prepended {
224 ret = ret.with_child(
225 Label::new(git_branch, style)
226 .contained()
227 .with_margin_right(item_spacing)
228 .aligned()
229 .left()
230 .into_any_named("title-project-branch"),
231 )
232 }
233 ret.into_any()
234 }
235
236 fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
237 let project = if active {
238 Some(self.project.clone())
239 } else {
240 None
241 };
242 ActiveCall::global(cx)
243 .update(cx, |call, cx| call.set_location(project.as_ref(), cx))
244 .detach_and_log_err(cx);
245 }
246
247 fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
248 if ActiveCall::global(cx).read(cx).room().is_none() {
249 self.contacts_popover = None;
250 }
251 cx.notify();
252 }
253
254 fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
255 let active_call = ActiveCall::global(cx);
256 let project = self.project.clone();
257 active_call
258 .update(cx, |call, cx| call.share_project(project, cx))
259 .detach_and_log_err(cx);
260 }
261
262 fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext<Self>) {
263 let active_call = ActiveCall::global(cx);
264 let project = self.project.clone();
265 active_call
266 .update(cx, |call, cx| call.unshare_project(project, cx))
267 .log_err();
268 }
269
270 pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext<Self>) {
271 if self.contacts_popover.take().is_none() {
272 let view = cx.add_view(|cx| {
273 ContactsPopover::new(
274 self.project.clone(),
275 self.user_store.clone(),
276 self.workspace.clone(),
277 cx,
278 )
279 });
280 cx.subscribe(&view, |this, _, event, cx| {
281 match event {
282 contacts_popover::Event::Dismissed => {
283 this.contacts_popover = None;
284 }
285 }
286
287 cx.notify();
288 })
289 .detach();
290 self.contacts_popover = Some(view);
291 }
292
293 cx.notify();
294 }
295
296 pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
297 self.user_menu.update(cx, |user_menu, cx| {
298 let items = if let Some(_) = self.user_store.read(cx).current_user() {
299 vec![
300 ContextMenuItem::action("Settings", zed_actions::OpenSettings),
301 ContextMenuItem::action("Theme", theme_selector::Toggle),
302 ContextMenuItem::separator(),
303 ContextMenuItem::action(
304 "Share Feedback",
305 feedback::feedback_editor::GiveFeedback,
306 ),
307 ContextMenuItem::action("Sign out", SignOut),
308 ]
309 } else {
310 vec![
311 ContextMenuItem::action("Settings", zed_actions::OpenSettings),
312 ContextMenuItem::action("Theme", theme_selector::Toggle),
313 ContextMenuItem::separator(),
314 ContextMenuItem::action(
315 "Share Feedback",
316 feedback::feedback_editor::GiveFeedback,
317 ),
318 ]
319 };
320 user_menu.show(Default::default(), AnchorCorner::TopRight, items, cx);
321 });
322 }
323
324 fn render_toggle_contacts_button(
325 &self,
326 theme: &Theme,
327 cx: &mut ViewContext<Self>,
328 ) -> AnyElement<Self> {
329 let titlebar = &theme.titlebar;
330
331 let badge = if self
332 .user_store
333 .read(cx)
334 .incoming_contact_requests()
335 .is_empty()
336 {
337 None
338 } else {
339 Some(
340 Empty::new()
341 .collapsed()
342 .contained()
343 .with_style(titlebar.toggle_contacts_badge)
344 .contained()
345 .with_margin_left(
346 titlebar
347 .toggle_contacts_button
348 .inactive_state()
349 .default
350 .icon_width,
351 )
352 .with_margin_top(
353 titlebar
354 .toggle_contacts_button
355 .inactive_state()
356 .default
357 .icon_width,
358 )
359 .aligned(),
360 )
361 };
362
363 Stack::new()
364 .with_child(
365 MouseEventHandler::<ToggleContactsMenu, Self>::new(0, cx, |state, _| {
366 let style = titlebar
367 .toggle_contacts_button
368 .in_state(self.contacts_popover.is_some())
369 .style_for(state);
370 Svg::new("icons/radix/person.svg")
371 .with_color(style.color)
372 .constrained()
373 .with_width(style.icon_width)
374 .aligned()
375 .constrained()
376 .with_width(style.button_width)
377 .with_height(style.button_width)
378 .contained()
379 .with_style(style.container)
380 })
381 .with_cursor_style(CursorStyle::PointingHand)
382 .on_click(MouseButton::Left, move |_, this, cx| {
383 this.toggle_contacts_popover(&Default::default(), cx)
384 })
385 .with_tooltip::<ToggleContactsMenu>(
386 0,
387 "Show contacts menu".into(),
388 Some(Box::new(ToggleContactsMenu)),
389 theme.tooltip.clone(),
390 cx,
391 ),
392 )
393 .with_children(badge)
394 .with_children(self.render_contacts_popover_host(titlebar, cx))
395 .into_any()
396 }
397 fn render_toggle_screen_sharing_button(
398 &self,
399 theme: &Theme,
400 room: &ModelHandle<Room>,
401 cx: &mut ViewContext<Self>,
402 ) -> AnyElement<Self> {
403 let icon;
404 let tooltip;
405 if room.read(cx).is_screen_sharing() {
406 icon = "icons/radix/desktop.svg";
407 tooltip = "Stop Sharing Screen"
408 } else {
409 icon = "icons/radix/desktop.svg";
410 tooltip = "Share Screen";
411 }
412
413 let active = room.read(cx).is_screen_sharing();
414 let titlebar = &theme.titlebar;
415 MouseEventHandler::<ToggleScreenSharing, Self>::new(0, cx, |state, _| {
416 let style = titlebar
417 .screen_share_button
418 .in_state(active)
419 .style_for(state);
420
421 Svg::new(icon)
422 .with_color(style.color)
423 .constrained()
424 .with_width(style.icon_width)
425 .aligned()
426 .constrained()
427 .with_width(style.button_width)
428 .with_height(style.button_width)
429 .contained()
430 .with_style(style.container)
431 })
432 .with_cursor_style(CursorStyle::PointingHand)
433 .on_click(MouseButton::Left, move |_, _, cx| {
434 toggle_screen_sharing(&Default::default(), cx)
435 })
436 .with_tooltip::<ToggleScreenSharing>(
437 0,
438 tooltip.into(),
439 Some(Box::new(ToggleScreenSharing)),
440 theme.tooltip.clone(),
441 cx,
442 )
443 .aligned()
444 .into_any()
445 }
446 fn render_toggle_mute(
447 &self,
448 theme: &Theme,
449 room: &ModelHandle<Room>,
450 cx: &mut ViewContext<Self>,
451 ) -> AnyElement<Self> {
452 let icon;
453 let tooltip;
454 let is_muted = room.read(cx).is_muted();
455 if is_muted {
456 icon = "icons/radix/mic-mute.svg";
457 tooltip = "Unmute microphone\nRight click for options";
458 } else {
459 icon = "icons/radix/mic.svg";
460 tooltip = "Mute microphone\nRight click for options";
461 }
462
463 let titlebar = &theme.titlebar;
464 MouseEventHandler::<ToggleMute, Self>::new(0, cx, |state, _| {
465 let style = titlebar
466 .toggle_microphone_button
467 .in_state(is_muted)
468 .style_for(state);
469 let image = Svg::new(icon)
470 .with_color(style.color)
471 .constrained()
472 .with_width(style.icon_width)
473 .aligned()
474 .constrained()
475 .with_width(style.button_width)
476 .with_height(style.button_width)
477 .contained()
478 .with_style(style.container);
479 if let Some(color) = style.container.background_color {
480 image.with_background_color(color)
481 } else {
482 image
483 }
484 })
485 .with_cursor_style(CursorStyle::PointingHand)
486 .on_click(MouseButton::Left, move |_, _, cx| {
487 toggle_mute(&Default::default(), cx)
488 })
489 .with_tooltip::<ToggleMute>(
490 0,
491 tooltip.into(),
492 Some(Box::new(ToggleMute)),
493 theme.tooltip.clone(),
494 cx,
495 )
496 .aligned()
497 .into_any()
498 }
499 fn render_toggle_deafen(
500 &self,
501 theme: &Theme,
502 room: &ModelHandle<Room>,
503 cx: &mut ViewContext<Self>,
504 ) -> AnyElement<Self> {
505 let icon;
506 let tooltip;
507 let is_deafened = room.read(cx).is_deafened().unwrap_or(false);
508 if is_deafened {
509 icon = "icons/radix/speaker-off.svg";
510 tooltip = "Unmute speakers\nRight click for options";
511 } else {
512 icon = "icons/radix/speaker-loud.svg";
513 tooltip = "Mute speakers\nRight click for options";
514 }
515
516 let titlebar = &theme.titlebar;
517 MouseEventHandler::<ToggleDeafen, Self>::new(0, cx, |state, _| {
518 let style = titlebar
519 .toggle_speakers_button
520 .in_state(is_deafened)
521 .style_for(state);
522 Svg::new(icon)
523 .with_color(style.color)
524 .constrained()
525 .with_width(style.icon_width)
526 .aligned()
527 .constrained()
528 .with_width(style.button_width)
529 .with_height(style.button_width)
530 .contained()
531 .with_style(style.container)
532 })
533 .with_cursor_style(CursorStyle::PointingHand)
534 .on_click(MouseButton::Left, move |_, _, cx| {
535 toggle_deafen(&Default::default(), cx)
536 })
537 .with_tooltip::<ToggleDeafen>(
538 0,
539 tooltip.into(),
540 Some(Box::new(ToggleDeafen)),
541 theme.tooltip.clone(),
542 cx,
543 )
544 .aligned()
545 .into_any()
546 }
547 fn render_leave_call(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
548 let icon = "icons/radix/exit.svg";
549 let tooltip = "Leave call";
550
551 let titlebar = &theme.titlebar;
552 MouseEventHandler::<LeaveCall, Self>::new(0, cx, |state, _| {
553 let style = titlebar.leave_call_button.style_for(state);
554 Svg::new(icon)
555 .with_color(style.color)
556 .constrained()
557 .with_width(style.icon_width)
558 .aligned()
559 .constrained()
560 .with_width(style.button_width)
561 .with_height(style.button_width)
562 .contained()
563 .with_style(style.container)
564 })
565 .with_cursor_style(CursorStyle::PointingHand)
566 .on_click(MouseButton::Left, move |_, _, cx| {
567 ActiveCall::global(cx)
568 .update(cx, |call, cx| call.hang_up(cx))
569 .detach_and_log_err(cx);
570 })
571 .with_tooltip::<LeaveCall>(
572 0,
573 tooltip.into(),
574 Some(Box::new(LeaveCall)),
575 theme.tooltip.clone(),
576 cx,
577 )
578 .aligned()
579 .into_any()
580 }
581 fn render_in_call_share_unshare_button(
582 &self,
583 workspace: &ViewHandle<Workspace>,
584 theme: &Theme,
585 cx: &mut ViewContext<Self>,
586 ) -> Option<AnyElement<Self>> {
587 let project = workspace.read(cx).project();
588 if project.read(cx).is_remote() {
589 return None;
590 }
591
592 let is_shared = project.read(cx).is_shared();
593 let label = if is_shared { "Stop Sharing" } else { "Share" };
594 let tooltip = if is_shared {
595 "Stop sharing project with call participants"
596 } else {
597 "Share project with call participants"
598 };
599
600 let titlebar = &theme.titlebar;
601
602 enum ShareUnshare {}
603 Some(
604 Stack::new()
605 .with_child(
606 MouseEventHandler::<ShareUnshare, Self>::new(0, cx, |state, _| {
607 //TODO: Ensure this button has consistent width for both text variations
608 let style = titlebar.share_button.inactive_state().style_for(state);
609 Label::new(label, style.text.clone())
610 .contained()
611 .with_style(style.container)
612 })
613 .with_cursor_style(CursorStyle::PointingHand)
614 .on_click(MouseButton::Left, move |_, this, cx| {
615 if is_shared {
616 this.unshare_project(&Default::default(), cx);
617 } else {
618 this.share_project(&Default::default(), cx);
619 }
620 })
621 .with_tooltip::<ShareUnshare>(
622 0,
623 tooltip.to_owned(),
624 None,
625 theme.tooltip.clone(),
626 cx,
627 ),
628 )
629 .aligned()
630 .contained()
631 .with_margin_left(theme.titlebar.item_spacing)
632 .into_any(),
633 )
634 }
635
636 fn render_user_menu_button(
637 &self,
638 theme: &Theme,
639 avatar: Option<Arc<ImageData>>,
640 cx: &mut ViewContext<Self>,
641 ) -> AnyElement<Self> {
642 let tooltip = theme.tooltip.clone();
643 let user_menu_button_style = if avatar.is_some() {
644 &theme.titlebar.user_menu.user_menu_button_online
645 } else {
646 &theme.titlebar.user_menu.user_menu_button_offline
647 };
648
649 let avatar_style = &user_menu_button_style.avatar;
650 Stack::new()
651 .with_child(
652 MouseEventHandler::<ToggleUserMenu, Self>::new(0, cx, |state, _| {
653 let style = user_menu_button_style
654 .user_menu
655 .inactive_state()
656 .style_for(state);
657
658 let mut dropdown = Flex::row().align_children_center();
659
660 if let Some(avatar_img) = avatar {
661 dropdown = dropdown.with_child(Self::render_face(
662 avatar_img,
663 *avatar_style,
664 Color::transparent_black(),
665 None,
666 ));
667 };
668
669 dropdown
670 .with_child(
671 Svg::new("icons/caret_down_8.svg")
672 .with_color(user_menu_button_style.icon.color)
673 .constrained()
674 .with_width(user_menu_button_style.icon.width)
675 .contained()
676 .into_any(),
677 )
678 .aligned()
679 .constrained()
680 .with_height(style.width)
681 .contained()
682 .with_style(style.container)
683 .into_any()
684 })
685 .with_cursor_style(CursorStyle::PointingHand)
686 .on_click(MouseButton::Left, move |_, this, cx| {
687 this.toggle_user_menu(&Default::default(), cx)
688 })
689 .with_tooltip::<ToggleUserMenu>(
690 0,
691 "Toggle user menu".to_owned(),
692 Some(Box::new(ToggleUserMenu)),
693 tooltip,
694 cx,
695 )
696 .contained(),
697 )
698 .with_child(
699 ChildView::new(&self.user_menu, cx)
700 .aligned()
701 .bottom()
702 .right(),
703 )
704 .into_any()
705 }
706
707 fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
708 let titlebar = &theme.titlebar;
709 MouseEventHandler::<SignIn, Self>::new(0, cx, |state, _| {
710 let style = titlebar.sign_in_button.inactive_state().style_for(state);
711 Label::new("Sign In", style.text.clone())
712 .contained()
713 .with_style(style.container)
714 })
715 .with_cursor_style(CursorStyle::PointingHand)
716 .on_click(MouseButton::Left, move |_, this, cx| {
717 let client = this.client.clone();
718 cx.app_context()
719 .spawn(|cx| async move { client.authenticate_and_connect(true, &cx).await })
720 .detach_and_log_err(cx);
721 })
722 .into_any()
723 }
724
725 fn render_contacts_popover_host<'a>(
726 &'a self,
727 _theme: &'a theme::Titlebar,
728 cx: &'a ViewContext<Self>,
729 ) -> Option<AnyElement<Self>> {
730 self.contacts_popover.as_ref().map(|popover| {
731 Overlay::new(ChildView::new(popover, cx))
732 .with_fit_mode(OverlayFitMode::SwitchAnchor)
733 .with_anchor_corner(AnchorCorner::TopRight)
734 .with_z_index(999)
735 .aligned()
736 .bottom()
737 .right()
738 .into_any()
739 })
740 }
741
742 fn render_collaborators(
743 &self,
744 workspace: &ViewHandle<Workspace>,
745 theme: &Theme,
746 room: &ModelHandle<Room>,
747 cx: &mut ViewContext<Self>,
748 ) -> Vec<Container<Self>> {
749 let mut participants = room
750 .read(cx)
751 .remote_participants()
752 .values()
753 .cloned()
754 .collect::<Vec<_>>();
755 participants.sort_by_cached_key(|p| p.user.github_login.clone());
756
757 participants
758 .into_iter()
759 .filter_map(|participant| {
760 let project = workspace.read(cx).project().read(cx);
761 let replica_id = project
762 .collaborators()
763 .get(&participant.peer_id)
764 .map(|collaborator| collaborator.replica_id);
765 let user = participant.user.clone();
766 Some(
767 Container::new(self.render_face_pile(
768 &user,
769 replica_id,
770 participant.peer_id,
771 Some(participant.location),
772 participant.muted,
773 participant.speaking,
774 workspace,
775 theme,
776 cx,
777 ))
778 .with_margin_right(theme.titlebar.face_pile_spacing),
779 )
780 })
781 .collect()
782 }
783
784 fn render_current_user(
785 &self,
786 workspace: &ViewHandle<Workspace>,
787 theme: &Theme,
788 user: &Arc<User>,
789 peer_id: PeerId,
790 muted: bool,
791 speaking: bool,
792 cx: &mut ViewContext<Self>,
793 ) -> AnyElement<Self> {
794 let replica_id = workspace.read(cx).project().read(cx).replica_id();
795
796 Container::new(self.render_face_pile(
797 user,
798 Some(replica_id),
799 peer_id,
800 None,
801 muted,
802 speaking,
803 workspace,
804 theme,
805 cx,
806 ))
807 .with_margin_right(theme.titlebar.item_spacing)
808 .into_any()
809 }
810
811 fn render_face_pile(
812 &self,
813 user: &User,
814 replica_id: Option<ReplicaId>,
815 peer_id: PeerId,
816 location: Option<ParticipantLocation>,
817 muted: bool,
818 speaking: bool,
819 workspace: &ViewHandle<Workspace>,
820 theme: &Theme,
821 cx: &mut ViewContext<Self>,
822 ) -> AnyElement<Self> {
823 let project_id = workspace.read(cx).project().read(cx).remote_id();
824 let room = ActiveCall::global(cx).read(cx).room();
825 let is_being_followed = workspace.read(cx).is_being_followed(peer_id);
826 let followed_by_self = room
827 .and_then(|room| {
828 Some(
829 is_being_followed
830 && room
831 .read(cx)
832 .followers_for(peer_id, project_id?)
833 .iter()
834 .any(|&follower| {
835 Some(follower) == workspace.read(cx).client().peer_id()
836 }),
837 )
838 })
839 .unwrap_or(false);
840
841 let leader_style = theme.titlebar.leader_avatar;
842 let follower_style = theme.titlebar.follower_avatar;
843
844 let microphone_state = if muted {
845 Some(theme.titlebar.muted)
846 } else if speaking {
847 Some(theme.titlebar.speaking)
848 } else {
849 None
850 };
851
852 let mut background_color = theme
853 .titlebar
854 .container
855 .background_color
856 .unwrap_or_default();
857
858 if let Some(replica_id) = replica_id {
859 if followed_by_self {
860 let selection = theme.editor.replica_selection_style(replica_id).selection;
861 background_color = Color::blend(selection, background_color);
862 background_color.a = 255;
863 }
864 }
865
866 let mut content = Stack::new()
867 .with_children(user.avatar.as_ref().map(|avatar| {
868 let face_pile = FacePile::new(theme.titlebar.follower_avatar_overlap)
869 .with_child(Self::render_face(
870 avatar.clone(),
871 Self::location_style(workspace, location, leader_style, cx),
872 background_color,
873 microphone_state,
874 ))
875 .with_children(
876 (|| {
877 let project_id = project_id?;
878 let room = room?.read(cx);
879 let followers = room.followers_for(peer_id, project_id);
880
881 Some(followers.into_iter().flat_map(|&follower| {
882 let remote_participant =
883 room.remote_participant_for_peer_id(follower);
884
885 let avatar = remote_participant
886 .and_then(|p| p.user.avatar.clone())
887 .or_else(|| {
888 if follower == workspace.read(cx).client().peer_id()? {
889 workspace
890 .read(cx)
891 .user_store()
892 .read(cx)
893 .current_user()?
894 .avatar
895 .clone()
896 } else {
897 None
898 }
899 })?;
900
901 Some(Self::render_face(
902 avatar.clone(),
903 follower_style,
904 background_color,
905 None,
906 ))
907 }))
908 })()
909 .into_iter()
910 .flatten(),
911 );
912
913 let mut container = face_pile
914 .contained()
915 .with_style(theme.titlebar.leader_selection);
916
917 if let Some(replica_id) = replica_id {
918 if followed_by_self {
919 let color = theme.editor.replica_selection_style(replica_id).selection;
920 container = container.with_background_color(color);
921 }
922 }
923
924 container
925 }))
926 .with_children((|| {
927 let replica_id = replica_id?;
928 let color = theme.editor.replica_selection_style(replica_id).cursor;
929 Some(
930 AvatarRibbon::new(color)
931 .constrained()
932 .with_width(theme.titlebar.avatar_ribbon.width)
933 .with_height(theme.titlebar.avatar_ribbon.height)
934 .aligned()
935 .bottom(),
936 )
937 })())
938 .into_any();
939
940 if let Some(location) = location {
941 if let Some(replica_id) = replica_id {
942 enum ToggleFollow {}
943
944 content = MouseEventHandler::<ToggleFollow, Self>::new(
945 replica_id.into(),
946 cx,
947 move |_, _| content,
948 )
949 .with_cursor_style(CursorStyle::PointingHand)
950 .on_click(MouseButton::Left, move |_, item, cx| {
951 if let Some(workspace) = item.workspace.upgrade(cx) {
952 if let Some(task) = workspace
953 .update(cx, |workspace, cx| workspace.toggle_follow(peer_id, cx))
954 {
955 task.detach_and_log_err(cx);
956 }
957 }
958 })
959 .with_tooltip::<ToggleFollow>(
960 peer_id.as_u64() as usize,
961 if is_being_followed {
962 format!("Unfollow {}", user.github_login)
963 } else {
964 format!("Follow {}", user.github_login)
965 },
966 Some(Box::new(FollowNextCollaborator)),
967 theme.tooltip.clone(),
968 cx,
969 )
970 .into_any();
971 } else if let ParticipantLocation::SharedProject { project_id } = location {
972 enum JoinProject {}
973
974 let user_id = user.id;
975 content = MouseEventHandler::<JoinProject, Self>::new(
976 peer_id.as_u64() as usize,
977 cx,
978 move |_, _| content,
979 )
980 .with_cursor_style(CursorStyle::PointingHand)
981 .on_click(MouseButton::Left, move |_, this, cx| {
982 if let Some(workspace) = this.workspace.upgrade(cx) {
983 let app_state = workspace.read(cx).app_state().clone();
984 workspace::join_remote_project(project_id, user_id, app_state, cx)
985 .detach_and_log_err(cx);
986 }
987 })
988 .with_tooltip::<JoinProject>(
989 peer_id.as_u64() as usize,
990 format!("Follow {} into external project", user.github_login),
991 Some(Box::new(FollowNextCollaborator)),
992 theme.tooltip.clone(),
993 cx,
994 )
995 .into_any();
996 }
997 }
998 content
999 }
1000
1001 fn location_style(
1002 workspace: &ViewHandle<Workspace>,
1003 location: Option<ParticipantLocation>,
1004 mut style: AvatarStyle,
1005 cx: &ViewContext<Self>,
1006 ) -> AvatarStyle {
1007 if let Some(location) = location {
1008 if let ParticipantLocation::SharedProject { project_id } = location {
1009 if Some(project_id) != workspace.read(cx).project().read(cx).remote_id() {
1010 style.image.grayscale = true;
1011 }
1012 } else {
1013 style.image.grayscale = true;
1014 }
1015 }
1016
1017 style
1018 }
1019
1020 fn render_face<V: View>(
1021 avatar: Arc<ImageData>,
1022 avatar_style: AvatarStyle,
1023 background_color: Color,
1024 microphone_state: Option<Color>,
1025 ) -> AnyElement<V> {
1026 Image::from_data(avatar)
1027 .with_style(avatar_style.image)
1028 .aligned()
1029 .contained()
1030 .with_background_color(microphone_state.unwrap_or(background_color))
1031 .with_corner_radius(avatar_style.outer_corner_radius)
1032 .constrained()
1033 .with_width(avatar_style.outer_width)
1034 .with_height(avatar_style.outer_width)
1035 .aligned()
1036 .into_any()
1037 }
1038
1039 fn render_connection_status(
1040 &self,
1041 status: &client::Status,
1042 cx: &mut ViewContext<Self>,
1043 ) -> Option<AnyElement<Self>> {
1044 enum ConnectionStatusButton {}
1045
1046 let theme = &theme::current(cx).clone();
1047 match status {
1048 client::Status::ConnectionError
1049 | client::Status::ConnectionLost
1050 | client::Status::Reauthenticating { .. }
1051 | client::Status::Reconnecting { .. }
1052 | client::Status::ReconnectionError { .. } => Some(
1053 Svg::new("icons/cloud_slash_12.svg")
1054 .with_color(theme.titlebar.offline_icon.color)
1055 .constrained()
1056 .with_width(theme.titlebar.offline_icon.width)
1057 .aligned()
1058 .contained()
1059 .with_style(theme.titlebar.offline_icon.container)
1060 .into_any(),
1061 ),
1062 client::Status::UpgradeRequired => Some(
1063 MouseEventHandler::<ConnectionStatusButton, Self>::new(0, cx, |_, _| {
1064 Label::new(
1065 "Please update Zed to collaborate",
1066 theme.titlebar.outdated_warning.text.clone(),
1067 )
1068 .contained()
1069 .with_style(theme.titlebar.outdated_warning.container)
1070 .aligned()
1071 })
1072 .with_cursor_style(CursorStyle::PointingHand)
1073 .on_click(MouseButton::Left, |_, _, cx| {
1074 auto_update::check(&Default::default(), cx);
1075 })
1076 .into_any(),
1077 ),
1078 _ => None,
1079 }
1080 }
1081}
1082
1083pub struct AvatarRibbon {
1084 color: Color,
1085}
1086
1087impl AvatarRibbon {
1088 pub fn new(color: Color) -> AvatarRibbon {
1089 AvatarRibbon { color }
1090 }
1091}
1092
1093impl Element<CollabTitlebarItem> for AvatarRibbon {
1094 type LayoutState = ();
1095
1096 type PaintState = ();
1097
1098 fn layout(
1099 &mut self,
1100 constraint: gpui::SizeConstraint,
1101 _: &mut CollabTitlebarItem,
1102 _: &mut LayoutContext<CollabTitlebarItem>,
1103 ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
1104 (constraint.max, ())
1105 }
1106
1107 fn paint(
1108 &mut self,
1109 scene: &mut SceneBuilder,
1110 bounds: RectF,
1111 _: RectF,
1112 _: &mut Self::LayoutState,
1113 _: &mut CollabTitlebarItem,
1114 _: &mut ViewContext<CollabTitlebarItem>,
1115 ) -> Self::PaintState {
1116 let mut path = PathBuilder::new();
1117 path.reset(bounds.lower_left());
1118 path.curve_to(
1119 bounds.origin() + vec2f(bounds.height(), 0.),
1120 bounds.origin(),
1121 );
1122 path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
1123 path.curve_to(bounds.lower_right(), bounds.upper_right());
1124 path.line_to(bounds.lower_left());
1125 scene.push_path(path.build(self.color, None));
1126 }
1127
1128 fn rect_for_text_range(
1129 &self,
1130 _: Range<usize>,
1131 _: RectF,
1132 _: RectF,
1133 _: &Self::LayoutState,
1134 _: &Self::PaintState,
1135 _: &CollabTitlebarItem,
1136 _: &ViewContext<CollabTitlebarItem>,
1137 ) -> Option<RectF> {
1138 None
1139 }
1140
1141 fn debug(
1142 &self,
1143 bounds: RectF,
1144 _: &Self::LayoutState,
1145 _: &Self::PaintState,
1146 _: &CollabTitlebarItem,
1147 _: &ViewContext<CollabTitlebarItem>,
1148 ) -> gpui::json::Value {
1149 json::json!({
1150 "type": "AvatarRibbon",
1151 "bounds": bounds.to_json(),
1152 "color": self.color.to_json(),
1153 })
1154 }
1155}