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