titlebar.rs

  1use gpui::{
  2    div, img, prelude::*, px, rems, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView,
  3    SharedString, Stateful, View, ViewContext, WindowContext,
  4};
  5use theme::ActiveFabricTheme;
  6use ui::{
  7    popover_menu, Button, ButtonCommon, ButtonStyle, Clickable, Color, LabelSize, PopoverMenu,
  8    Tooltip,
  9};
 10
 11#[derive(Clone, Copy, Debug)]
 12pub struct PeerId(pub u32);
 13
 14#[derive(Clone, Copy, Debug)]
 15pub struct ProjectId(u64);
 16
 17pub trait TitlebarDelegate: 'static + Sized {
 18    fn toggle_following(&mut self, peer_index: PeerId, cx: &mut ViewContext<Self>);
 19}
 20
 21impl TitlebarDelegate for () {
 22    fn toggle_following(&mut self, peer: PeerId, _cx: &mut ViewContext<Self>) {
 23        log::info!("toggle following {:?}", peer);
 24    }
 25}
 26
 27#[derive(IntoElement)]
 28pub struct Titlebar<D: TitlebarDelegate = ()> {
 29    pub delegate: View<D>,
 30    pub full_screen: bool,
 31    pub project_host: Option<ProjectHost<D>>,
 32    pub projects: Projects<D>,
 33    pub branches: Option<Branches>,
 34    pub collaborators: Vec<FacePile>,
 35}
 36
 37#[derive(IntoElement)]
 38pub struct ProjectHost<D: TitlebarDelegate> {
 39    pub delegate: View<D>,
 40    pub id: PeerId,
 41    pub login: SharedString,
 42    pub peer_index: u32,
 43}
 44
 45#[derive(IntoElement)]
 46pub struct Projects<D: TitlebarDelegate> {
 47    pub delegate: View<D>,
 48    pub current: SharedString,
 49    pub recent: Vec<Project>,
 50}
 51
 52#[derive(Clone)]
 53pub struct Project {
 54    pub name: SharedString,
 55    pub id: ProjectId,
 56}
 57
 58pub struct ProjectsMenu<D> {
 59    delegate: View<D>,
 60    focus: FocusHandle,
 61    recent: Vec<Project>,
 62}
 63
 64pub struct Branches {
 65    pub current: SharedString,
 66}
 67
 68#[derive(IntoElement, Default)]
 69pub struct FacePile {
 70    pub faces: Vec<Avatar>,
 71}
 72
 73#[derive(IntoElement)]
 74pub struct Avatar {
 75    pub image_uri: SharedString,
 76    pub audio_status: AudioStatus,
 77    pub available: Option<bool>,
 78    pub shape: AvatarShape,
 79}
 80
 81pub enum AvatarShape {
 82    Square,
 83    Circle,
 84}
 85
 86impl<D: TitlebarDelegate> RenderOnce for Titlebar<D> {
 87    type Output = Stateful<Div>;
 88
 89    fn render(self, cx: &mut ui::prelude::WindowContext) -> Self::Output {
 90        div()
 91            .flex()
 92            .flex_col()
 93            .id("titlebar")
 94            .justify_between()
 95            .w_full()
 96            .h(rems(1.75))
 97            // Set a non-scaling min-height here to ensure the titlebar is
 98            // always at least the height of the traffic lights.
 99            .min_h(px(32.))
100            .pl_2()
101            .when(self.full_screen, |this| {
102                // Use pixels here instead of a rem-based size because the macOS traffic
103                // lights are a static size, and don't scale with the rest of the UI.
104                this.pl(px(80.))
105            })
106            .bg(cx.theme().denim.default.background)
107            .on_click(|event, cx| {
108                if event.up.click_count == 2 {
109                    cx.zoom_window();
110                }
111            })
112            // left side
113            .child(
114                div()
115                    .flex()
116                    .flex_row()
117                    .gap_1()
118                    .children(self.project_host)
119                    .child(self.projects), // .children(self.render_project_branch(cx))
120                                           // .children(self.render_collaborators(cx)),
121            )
122        // right side
123        // .child(
124        //     div()
125        //         .flex()
126        //         .flex_row()
127        //         .gap_1()
128        //         .pr_1()
129        //         .when_some(room, |this, room| {
130        //             let room = room.read(cx);
131        //             let project = self.project.read(cx);
132        //             let is_local = project.is_local();
133        //             let is_shared = is_local && project.is_shared();
134        //             let is_muted = room.is_muted(cx);
135        //             let is_deafened = room.is_deafened().unwrap_or(false);
136        //             let is_screen_sharing = room.is_screen_sharing();
137
138        //             this.when(is_local, |this| {
139        //                 this.child(
140        //                     Button::new(
141        //                         "toggle_sharing",
142        //                         if is_shared { "Unshare" } else { "Share" },
143        //                     )
144        //                     .style(ButtonStyle::Subtle)
145        //                     .label_size(LabelSize::Small)
146        //                     .on_click(cx.listener(
147        //                         move |this, _, cx| {
148        //                             if is_shared {
149        //                                 this.unshare_project(&Default::default(), cx);
150        //                             } else {
151        //                                 this.share_project(&Default::default(), cx);
152        //                             }
153        //                         },
154        //                     )),
155        //                 )
156        //             })
157        //             .child(
158        //                 IconButton::new("leave-call", ui::Icon::Exit)
159        //                     .style(ButtonStyle::Subtle)
160        //                     .icon_size(IconSize::Small)
161        //                     .on_click(move |_, cx| {
162        //                         ActiveCall::global(cx)
163        //                             .update(cx, |call, cx| call.hang_up(cx))
164        //                             .detach_and_log_err(cx);
165        //                     }),
166        //             )
167        //             .child(
168        //                 IconButton::new(
169        //                     "mute-microphone",
170        //                     if is_muted {
171        //                         ui::Icon::MicMute
172        //                     } else {
173        //                         ui::Icon::Mic
174        //                     },
175        //                 )
176        //                 .style(ButtonStyle::Subtle)
177        //                 .icon_size(IconSize::Small)
178        //                 .selected(is_muted)
179        //                 .on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
180        //             )
181        //             .child(
182        //                 IconButton::new(
183        //                     "mute-sound",
184        //                     if is_deafened {
185        //                         ui::Icon::AudioOff
186        //                     } else {
187        //                         ui::Icon::AudioOn
188        //                     },
189        //                 )
190        //                 .style(ButtonStyle::Subtle)
191        //                 .icon_size(IconSize::Small)
192        //                 .selected(is_deafened)
193        //                 .tooltip(move |cx| {
194        //                     Tooltip::with_meta("Deafen Audio", None, "Mic will be muted", cx)
195        //                 })
196        //                 .on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
197        //             )
198        //             .child(
199        //                 IconButton::new("screen-share", ui::Icon::Screen)
200        //                     .style(ButtonStyle::Subtle)
201        //                     .icon_size(IconSize::Small)
202        //                     .selected(is_screen_sharing)
203        //                     .on_click(move |_, cx| {
204        //                         crate::toggle_screen_sharing(&Default::default(), cx)
205        //                     }),
206        //             )
207        //         })
208        //         .map(|el| {
209        //             let status = self.client.status();
210        //             let status = &*status.borrow();
211        //             if matches!(status, client::Status::Connected { .. }) {
212        //                 el.child(self.render_user_menu_button(cx))
213        //             } else {
214        //                 el.children(self.render_connection_status(status, cx))
215        //                     .child(self.render_sign_in_button(cx))
216        //                     .child(self.render_user_menu_button(cx))
217        //             }
218        //         }),
219        // )
220    }
221}
222
223impl<D: TitlebarDelegate> RenderOnce for ProjectHost<D> {
224    type Output = Button;
225
226    fn render(self, _: &mut WindowContext) -> Self::Output {
227        let delegate = self.delegate;
228        Button::new("project-host", self.login)
229            .color(Color::Player(self.peer_index))
230            .style(ButtonStyle::Subtle)
231            .label_size(LabelSize::Small)
232            .tooltip(move |cx| Tooltip::text("Toggle following", cx))
233            .on_click(move |_, cx| {
234                let host_id = self.id;
235                delegate.update(cx, |this, cx| this.toggle_following(host_id, cx))
236            })
237    }
238}
239
240impl<D: 'static> EventEmitter<DismissEvent> for ProjectsMenu<D> {}
241
242impl<D: TitlebarDelegate> Render for ProjectsMenu<D> {
243    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
244        todo!()
245    }
246}
247
248impl<D: TitlebarDelegate> FocusableView for ProjectsMenu<D> {
249    fn focus_handle(&self, _cx: &gpui::AppContext) -> gpui::FocusHandle {
250        self.focus.clone()
251    }
252}
253
254impl<D: TitlebarDelegate> RenderOnce for Projects<D> {
255    type Output = PopoverMenu<ProjectsMenu<D>>;
256
257    fn render(self, _cx: &mut WindowContext) -> Self::Output {
258        let delegate = self.delegate;
259        let recent_projects = self.recent;
260
261        popover_menu("recent-projects")
262            .trigger(
263                Button::new("trigger", self.current)
264                    .style(ButtonStyle::Subtle)
265                    .label_size(LabelSize::Small)
266                    .tooltip(move |cx| Tooltip::text("Recent Projects", cx)),
267            )
268            .menu(move |cx| {
269                if recent_projects.is_empty() {
270                    None
271                } else {
272                    Some(cx.new_view(|cx| ProjectsMenu {
273                        delegate: delegate.clone(),
274                        focus: cx.focus_handle(),
275                        recent: recent_projects.clone(),
276                    }))
277                }
278            })
279    }
280}
281
282impl RenderOnce for FacePile {
283    type Output = Div;
284
285    fn render(self, _: &mut WindowContext) -> Self::Output {
286        let face_count = self.faces.len();
287        div()
288            .p_1()
289            .flex()
290            .items_center()
291            .children(self.faces.into_iter().enumerate().map(|(ix, avatar)| {
292                let last_child = ix == face_count - 1;
293                div()
294                    .z_index((face_count - ix) as u8)
295                    .when(!last_child, |div| div.neg_mr_1())
296                    .child(avatar)
297            }))
298    }
299}
300
301impl RenderOnce for Avatar {
302    type Output = Div;
303
304    fn render(self, cx: &mut WindowContext) -> Self::Output {
305        div()
306            .map(|this| match self.shape {
307                AvatarShape::Square => this.rounded_md(),
308                AvatarShape::Circle => this.rounded_full(),
309            })
310            .map(|this| match self.audio_status {
311                AudioStatus::None => this,
312                AudioStatus::Muted => this.border_color(cx.theme().muted),
313                AudioStatus::Speaking => this.border_color(cx.theme().speaking),
314            })
315            .size(cx.rem_size() + px(2.))
316            .child(
317                img(self.image_uri)
318                    .size(cx.rem_size())
319                    .bg(cx.theme().cotton.disabled.background),
320            )
321            .children(self.available.map(|is_free| {
322                // Non-integer sizes result in non-round indicators.
323                let indicator_size = (cx.rem_size() * 0.4).round();
324
325                div()
326                    .absolute()
327                    .z_index(1)
328                    .bg(if is_free {
329                        cx.theme().positive.default.background
330                    } else {
331                        cx.theme().negative.default.background
332                    })
333                    .size(indicator_size)
334                    .rounded(indicator_size)
335                    .bottom_0()
336                    .right_0()
337            }))
338    }
339}
340
341pub enum AudioStatus {
342    None,
343    Muted,
344    Speaking,
345}
346
347// impl Titlebar {
348//     pub fn render_project_host(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
349//         let host = self.project.read(cx).host()?;
350//         let host = self.user_store.read(cx).get_cached_user(host.user_id)?;
351//         let participant_index = self
352//             .user_store
353//             .read(cx)
354//             .participant_indices()
355//             .get(&host.id)?;
356//         Some(
357//             div().border().border_color(gpui::red()).child(
358//                 Button::new("project_owner_trigger", host.github_login.clone())
359//                     .color(Color::Player(participant_index.0))
360//                     .style(ButtonStyle::Subtle)
361//                     .label_size(LabelSize::Small)
362//                     .tooltip(move |cx| Tooltip::text("Toggle following", cx)),
363//             ),
364//         )
365//     }
366
367//     pub fn render_project_branch(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
368//         let entry = {
369//             let mut names_and_branches =
370//                 self.project.read(cx).visible_worktrees(cx).map(|worktree| {
371//                     let worktree = worktree.read(cx);
372//                     worktree.root_git_entry()
373//                 });
374
375//             names_and_branches.next().flatten()
376//         };
377//         let workspace = self.workspace.upgrade()?;
378//         let branch_name = entry
379//             .as_ref()
380//             .and_then(RepositoryEntry::branch)
381//             .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?;
382//         Some(
383//             popover_menu("project_branch_trigger")
384//                 .trigger(
385//                     Button::new("project_branch_trigger", branch_name)
386//                         .color(Color::Muted)
387//                         .style(ButtonStyle::Subtle)
388//                         .label_size(LabelSize::Small)
389//                         .tooltip(move |cx| {
390//                             Tooltip::with_meta(
391//                                 "Recent Branches",
392//                                 Some(&ToggleVcsMenu),
393//                                 "Local branches only",
394//                                 cx,
395//                             )
396//                         }),
397//                 )
398//                 .menu(move |cx| Self::render_vcs_popover(workspace.clone(), cx)),
399//         )
400//     }
401
402//     fn render_collaborator(
403//         &self,
404//         user: &Arc<User>,
405//         peer_id: PeerId,
406//         is_present: bool,
407//         is_speaking: bool,
408//         is_muted: bool,
409//         room: &Room,
410//         project_id: Option<u64>,
411//         current_user: &Arc<User>,
412//     ) -> Option<FacePile> {
413//         let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id));
414
415//         let pile = FacePile::default()
416//             .child(
417//                 Avatar::new(user.avatar_uri.clone())
418//                     .grayscale(!is_present)
419//                     .border_color(if is_speaking {
420//                         gpui::blue()
421//                     } else if is_muted {
422//                         gpui::red()
423//                     } else {
424//                         Hsla::default()
425//                     }),
426//             )
427//             .children(followers.iter().filter_map(|follower_peer_id| {
428//                 let follower = room
429//                     .remote_participants()
430//                     .values()
431//                     .find_map(|p| (p.peer_id == *follower_peer_id).then_some(&p.user))
432//                     .or_else(|| {
433//                         (self.client.peer_id() == Some(*follower_peer_id)).then_some(current_user)
434//                     })?
435//                     .clone();
436
437//                 Some(Avatar::new(follower.avatar_uri.clone()))
438//             }));
439
440//         Some(pile)
441//     }
442// }