Add `Facepile` and `PlayerStack` components

Marshall Bowers created

Change summary

crates/storybook2/src/stories/components.rs          |   1 
crates/storybook2/src/stories/components/facepile.rs |  35 +++
crates/storybook2/src/story_selector.rs              |   2 
crates/ui2/src/components.rs                         |   4 
crates/ui2/src/components/facepile.rs                |  33 +++
crates/ui2/src/components/player_stack.rs            |  72 +++++++
crates/ui2/src/elements.rs                           |   2 
crates/ui2/src/elements/player.rs                    | 133 ++++++++++++++
crates/ui2/src/static_data.rs                        |  34 +++
9 files changed, 314 insertions(+), 2 deletions(-)

Detailed changes

crates/storybook2/src/stories/components/facepile.rs 🔗

@@ -0,0 +1,35 @@
+use std::marker::PhantomData;
+
+use ui::prelude::*;
+use ui::{static_players, Facepile};
+
+use crate::story::Story;
+
+#[derive(Element)]
+pub struct FacepileStory<S: 'static + Send + Sync> {
+    state_type: PhantomData<S>,
+}
+
+impl<S: 'static + Send + Sync> FacepileStory<S> {
+    pub fn new() -> Self {
+        Self {
+            state_type: PhantomData,
+        }
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
+        let players = static_players();
+
+        Story::container(cx)
+            .child(Story::title_for::<_, Facepile<S>>(cx))
+            .child(Story::label(cx, "Default"))
+            .child(
+                div()
+                    .flex()
+                    .gap_3()
+                    .child(Facepile::new(players.clone().into_iter().take(1)))
+                    .child(Facepile::new(players.clone().into_iter().take(2)))
+                    .child(Facepile::new(players.clone().into_iter().take(3))),
+            )
+    }
+}

crates/storybook2/src/story_selector.rs 🔗

@@ -40,6 +40,7 @@ pub enum ComponentStory {
     Breadcrumb,
     Buffer,
     ChatPanel,
+    Facepile,
     Panel,
     ProjectPanel,
     Tab,
@@ -61,6 +62,7 @@ impl ComponentStory {
             Self::Buffer => components::buffer::BufferStory::new().into_any(),
             Self::Breadcrumb => components::breadcrumb::BreadcrumbStory::new().into_any(),
             Self::ChatPanel => components::chat_panel::ChatPanelStory::new().into_any(),
+            Self::Facepile => components::facepile::FacepileStory::new().into_any(),
             Self::Panel => components::panel::PanelStory::new().into_any(),
             Self::ProjectPanel => components::project_panel::ProjectPanelStory::new().into_any(),
             Self::Tab => components::tab::TabStory::new().into_any(),

crates/ui2/src/components.rs 🔗

@@ -3,10 +3,12 @@ mod breadcrumb;
 mod buffer;
 mod chat_panel;
 mod editor_pane;
+mod facepile;
 mod icon_button;
 mod list;
 mod panel;
 mod panes;
+mod player_stack;
 mod project_panel;
 mod status_bar;
 mod tab;
@@ -21,10 +23,12 @@ pub use breadcrumb::*;
 pub use buffer::*;
 pub use chat_panel::*;
 pub use editor_pane::*;
+pub use facepile::*;
 pub use icon_button::*;
 pub use list::*;
 pub use panel::*;
 pub use panes::*;
+pub use player_stack::*;
 pub use project_panel::*;
 pub use status_bar::*;
 pub use tab::*;

crates/ui2/src/components/facepile.rs 🔗

@@ -0,0 +1,33 @@
+use std::marker::PhantomData;
+
+use crate::prelude::*;
+use crate::{theme, Avatar, Player};
+
+#[derive(Element)]
+pub struct Facepile<S: 'static + Send + Sync> {
+    state_type: PhantomData<S>,
+    players: Vec<Player>,
+}
+
+impl<S: 'static + Send + Sync> Facepile<S> {
+    pub fn new<P: Iterator<Item = Player>>(players: P) -> Self {
+        Self {
+            state_type: PhantomData,
+            players: players.collect(),
+        }
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
+        let theme = theme(cx);
+        let player_count = self.players.len();
+        let player_list = self.players.iter().enumerate().map(|(ix, player)| {
+            let isnt_last = ix < player_count - 1;
+
+            div()
+                // TODO: Blocked on negative margins.
+                // .when(isnt_last, |div| div.neg_mr_1())
+                .child(Avatar::new(player.avatar_src().to_string()))
+        });
+        div().p_1().flex().items_center().children(player_list)
+    }
+}

crates/ui2/src/components/player_stack.rs 🔗

@@ -0,0 +1,72 @@
+use std::marker::PhantomData;
+
+use crate::prelude::*;
+use crate::{Avatar, Facepile, PlayerWithCallStatus};
+
+#[derive(Element)]
+pub struct PlayerStack<S: 'static + Send + Sync> {
+    state_type: PhantomData<S>,
+    player_with_call_status: PlayerWithCallStatus,
+}
+
+impl<S: 'static + Send + Sync> PlayerStack<S> {
+    pub fn new(player_with_call_status: PlayerWithCallStatus) -> Self {
+        Self {
+            state_type: PhantomData,
+            player_with_call_status,
+        }
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
+        let system_color = SystemColor::new();
+        let player = self.player_with_call_status.get_player();
+        self.player_with_call_status.get_call_status();
+
+        let followers = self
+            .player_with_call_status
+            .get_call_status()
+            .followers
+            .as_ref()
+            .map(|followers| followers.clone());
+
+        // if we have no followers return a slightly different element
+        // if mic_status == muted add a red ring to avatar
+
+        div()
+            .h_full()
+            .flex()
+            .flex_col()
+            .gap_px()
+            .justify_center()
+            .child(
+                div().flex().justify_center().w_full().child(
+                    div()
+                        .w_4()
+                        .h_0p5()
+                        .rounded_sm()
+                        .fill(player.cursor_color(cx)),
+                ),
+            )
+            .child(
+                div()
+                    .flex()
+                    .items_center()
+                    .justify_center()
+                    .h_6()
+                    .pl_1()
+                    .rounded_lg()
+                    .fill(if followers.is_none() {
+                        system_color.transparent
+                    } else {
+                        player.selection_color(cx)
+                    })
+                    .child(Avatar::new(player.avatar_src().to_string()))
+                    .children(followers.map(|followers| {
+                        div()
+                            // TODO: Blocked on negative margins.
+                            // .neg_ml_2()
+                            .child(Facepile::new(followers.into_iter()))
+                    })),
+            )
+    }
+}

crates/ui2/src/elements.rs 🔗

@@ -3,6 +3,7 @@ mod button;
 mod icon;
 mod input;
 mod label;
+mod player;
 mod stack;
 mod tool_divider;
 
@@ -11,5 +12,6 @@ pub use button::*;
 pub use icon::*;
 pub use input::*;
 pub use label::*;
+pub use player::*;
 pub use stack::*;
 pub use tool_divider::*;

crates/ui2/src/elements/player.rs 🔗

@@ -0,0 +1,133 @@
+use gpui3::{Hsla, ViewContext};
+
+use crate::theme;
+
+#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub enum PlayerStatus {
+    #[default]
+    Offline,
+    Online,
+    InCall,
+    Away,
+    DoNotDisturb,
+    Invisible,
+}
+
+#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub enum MicStatus {
+    Muted,
+    #[default]
+    Unmuted,
+}
+
+#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub enum VideoStatus {
+    On,
+    #[default]
+    Off,
+}
+
+#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub enum ScreenShareStatus {
+    Shared,
+    #[default]
+    NotShared,
+}
+
+#[derive(Clone)]
+pub struct PlayerCallStatus {
+    pub mic_status: MicStatus,
+    /// Indicates if the player is currently speaking
+    /// And the intensity of the volume coming through
+    ///
+    /// 0.0 - 1.0
+    pub voice_activity: f32,
+    pub video_status: VideoStatus,
+    pub screen_share_status: ScreenShareStatus,
+    pub in_current_project: bool,
+    pub disconnected: bool,
+    pub following: Option<Vec<Player>>,
+    pub followers: Option<Vec<Player>>,
+}
+
+impl PlayerCallStatus {
+    pub fn new() -> Self {
+        Self {
+            mic_status: MicStatus::default(),
+            voice_activity: 0.,
+            video_status: VideoStatus::default(),
+            screen_share_status: ScreenShareStatus::default(),
+            in_current_project: true,
+            disconnected: false,
+            following: None,
+            followers: None,
+        }
+    }
+}
+
+#[derive(PartialEq, Clone)]
+pub struct Player {
+    index: usize,
+    avatar_src: String,
+    username: String,
+    status: PlayerStatus,
+}
+
+#[derive(Clone)]
+pub struct PlayerWithCallStatus {
+    player: Player,
+    call_status: PlayerCallStatus,
+}
+
+impl PlayerWithCallStatus {
+    pub fn new(player: Player, call_status: PlayerCallStatus) -> Self {
+        Self {
+            player,
+            call_status,
+        }
+    }
+
+    pub fn get_player(&self) -> &Player {
+        &self.player
+    }
+
+    pub fn get_call_status(&self) -> &PlayerCallStatus {
+        &self.call_status
+    }
+}
+
+impl Player {
+    pub fn new(index: usize, avatar_src: String, username: String) -> Self {
+        Self {
+            index,
+            avatar_src,
+            username,
+            status: Default::default(),
+        }
+    }
+
+    pub fn set_status(mut self, status: PlayerStatus) -> Self {
+        self.status = status;
+        self
+    }
+
+    pub fn cursor_color<S: 'static>(&self, cx: &mut ViewContext<S>) -> Hsla {
+        let theme = theme(cx);
+        let index = self.index % 8;
+        theme.players[self.index].cursor
+    }
+
+    pub fn selection_color<S: 'static>(&self, cx: &mut ViewContext<S>) -> Hsla {
+        let theme = theme(cx);
+        let index = self.index % 8;
+        theme.players[self.index].selection
+    }
+
+    pub fn avatar_src(&self) -> &str {
+        &self.avatar_src
+    }
+
+    pub fn index(&self) -> usize {
+        self.index
+    }
+}

crates/ui2/src/static_data.rs 🔗

@@ -3,8 +3,8 @@ use std::str::FromStr;
 
 use crate::{
     Buffer, BufferRow, BufferRows, Editor, FileSystemStatus, GitStatus, HighlightColor,
-    HighlightedLine, HighlightedText, Icon, Label, LabelColor, ListEntry, ListItem, Symbol, Tab,
-    Theme, ToggleState,
+    HighlightedLine, HighlightedText, Icon, Label, LabelColor, ListEntry, ListItem, Player, Symbol,
+    Tab, Theme, ToggleState,
 };
 
 pub fn static_tabs_example<S: 'static + Send + Sync + Clone>() -> Vec<Tab<S>> {
@@ -100,6 +100,36 @@ pub fn static_tabs_3<S: 'static + Send + Sync + Clone>() -> Vec<Tab<S>> {
     vec![Tab::new().git_status(GitStatus::Created).current(true)]
 }
 
+pub fn static_players() -> Vec<Player> {
+    vec![
+        Player::new(
+            0,
+            "https://avatars.githubusercontent.com/u/1714999?v=4".into(),
+            "nathansobo".into(),
+        ),
+        Player::new(
+            1,
+            "https://avatars.githubusercontent.com/u/326587?v=4".into(),
+            "maxbrunsfeld".into(),
+        ),
+        Player::new(
+            2,
+            "https://avatars.githubusercontent.com/u/482957?v=4".into(),
+            "as-cii".into(),
+        ),
+        Player::new(
+            3,
+            "https://avatars.githubusercontent.com/u/1714999?v=4".into(),
+            "iamnbutler".into(),
+        ),
+        Player::new(
+            4,
+            "https://avatars.githubusercontent.com/u/1486634?v=4".into(),
+            "maxdeviant".into(),
+        ),
+    ]
+}
+
 pub fn static_project_panel_project_items<S: 'static + Send + Sync + Clone>() -> Vec<ListItem<S>> {
     vec![
         ListEntry::new(Label::new("zed"))