Files
Ely-GPUI-Components/src/collab/peers.rs
T
ZacharyZhang-NY b2211f842e T22 round 1 fixes
- a remote cursor shows only while its caret sits inside the field, sideways too
- presence avatars keep a transparent border for the focus ring
- the access list shows each role, as words when it cannot change
- status dots take the theme's size
- peer and member pictures load from files and web addresses alike
2026-09-26 05:22:50 -04:00

336 lines
12 KiB
Rust

use std::{rc::Rc, time::Duration};
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, Hsla, InteractiveElement,
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement,
Styled, Window, div, prelude::*, pulsating_between, transparent_black,
};
use smallvec::SmallVec;
use crate::{
buttons::{Button, ButtonVariant},
data_display::{Avatar, more, tucked},
documents::source,
forms::{OnValue, Run},
overlays::HoverCard,
primitives::{FocusRing, hand_back, hold_focus},
theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize},
typography::tabular,
};
/// One breath of the live dot.
const BREATH: Duration = Duration::from_millis(2000);
/// How strongly a peer's color tints the bar over a followed view.
const TINT: f32 = 0.12;
/// Someone in the document with you: a key, a name, a picture, where they are, and which of the theme's chart colors marks their cursor and selection.
#[derive(Clone, Debug, PartialEq)]
pub struct Peer {
pub key: SharedString,
pub name: SharedString,
pub picture: Option<SharedString>,
pub place: Option<SharedString>,
pub hue: usize,
}
impl Peer {
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>, hue: usize) -> Self {
Self {
key: key.into(),
name: name.into(),
picture: None,
place: None,
hue,
}
}
/// Their color, from the theme's chart colors.
pub fn color(&self, cx: &App) -> Hsla {
let chart = &cx.theme().colors.chart;
chart[self.hue % chart.len()]
}
pub(crate) fn avatar(&self, id: impl Into<ElementId>, size: AvatarSize) -> Avatar {
let avatar = Avatar::new(id, self.name.clone()).size(size);
match &self.picture {
Some(picture) => avatar.image(source(picture)),
None => avatar,
}
}
}
/// Who else is here, their avatars overlapping, each ringed in their color: resting on one says where they are; a press follows them. Past `max`, the rest show as a count.
#[derive(IntoElement)]
pub struct PresenceAvatars {
id: ElementId,
peers: Vec<Peer>,
max: usize,
following: Option<SharedString>,
on_follow: Option<OnValue>,
}
impl PresenceAvatars {
pub fn new(id: impl Into<ElementId>, peers: impl IntoIterator<Item = Peer>) -> Self {
Self {
id: id.into(),
peers: peers.into_iter().collect(),
max: 4,
following: None,
on_follow: None,
}
}
/// How many show before the rest become a count.
pub fn max(mut self, max: usize) -> Self {
assert!(max > 0, "presence shows at least one avatar");
self.max = max;
self
}
/// Whom the view follows now, by key; a press on someone asks to follow them.
pub fn follow(
mut self,
following: Option<SharedString>,
on_follow: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.following = following;
self.on_follow = Some(Rc::new(on_follow));
self
}
}
impl RenderOnce for PresenceAvatars {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let size = AvatarSize::Sm;
let rest = self.peers.len().saturating_sub(self.max);
let shown: Vec<AnyElement> = self
.peers
.iter()
.take(self.max)
.enumerate()
.map(|(ix, peer)| {
let color = peer.color(cx);
let following = self.following.as_ref() == Some(&peer.key);
let (key, name, place) = (peer.key.clone(), peer.name.clone(), peer.place.clone());
let follow = self.on_follow.clone();
let face = tucked(ix == 0, size)
.id((self.id.clone(), format!("peer-{key}")))
.rounded_full()
.border_1()
.border_color(transparent_black())
.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(follow, |face, follow| {
let key = key.clone();
face.on_click(move |_, window, cx| {
log::info!("presence: follow {key}");
follow(&key, window, cx)
})
})
.child(
peer.avatar((self.id.clone(), format!("avatar-{key}")), size)
.ring_color(color),
);
HoverCard::new(
(self.id.clone(), format!("card-{key}")),
face,
move |_, cx| {
let colors = &cx.theme().colors;
div()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.flex()
.items_center()
.gap_1p5()
.child(
div()
.size(cx.theme().status_dot())
.rounded_full()
.bg(color),
)
.child(
div().font_weight(FontWeight::MEDIUM).child(name.clone()),
),
)
.children(
place
.clone()
.map(|place| div().text_color(colors.fg_muted).child(place)),
)
.child(div().text_color(colors.fg_subtle).child(if following {
"Following · press to stop"
} else {
"Press to follow"
}))
},
)
.into_any_element()
})
.collect();
div()
.flex()
.items_center()
.children(shown)
.when(rest > 0, |row| {
row.child(tucked(false, size).child(more(rest, size, cx)))
})
}
}
/// Whether a document is live: a dot that breathes while others are here, and how many are.
#[derive(IntoElement)]
pub struct LiveIndicator {
id: ElementId,
others: usize,
}
impl LiveIndicator {
/// `others` counts the people here besides you.
pub fn new(id: impl Into<ElementId>, others: usize) -> Self {
Self {
id: id.into(),
others,
}
}
}
impl RenderOnce for LiveIndicator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let live = self.others > 0;
let dot = div().size(theme.status_dot()).rounded_full().bg(if live {
colors.success
} else {
colors.fg_subtle
});
let dot: AnyElement = if live && !theme.reduced_motion {
dot.with_animation(
(self.id.clone(), "breath"),
Animation::new(BREATH)
.repeat()
.with_easing(pulsating_between(0.35, 1.0)),
|dot, t| dot.opacity(t),
)
.into_any_element()
} else {
dot.into_any_element()
};
div()
.id(self.id)
.flex()
.flex_none()
.items_center()
.gap_1p5()
.px_2()
.py_0p5()
.rounded_full()
.border_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Xs))
.text_color(if live { colors.fg } else { colors.fg_muted })
.child(dot)
.child(tabular(div()).child(if live {
format!("Live · {} here", self.others + 1)
} else {
"Only you".to_string()
}))
}
}
/// Following someone's view: a frame in their color around what they see, a bar that names them, and a way to stop; Escape stops too.
#[derive(IntoElement)]
pub struct FollowMode {
id: ElementId,
peer: Option<Peer>,
on_stop: Run,
body: SmallVec<[AnyElement; 2]>,
}
impl FollowMode {
/// `peer` is whom the view follows, when anyone.
pub fn new(
id: impl Into<ElementId>,
peer: Option<Peer>,
on_stop: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
peer,
on_stop: Rc::new(on_stop),
body: SmallVec::new(),
}
}
}
impl ParentElement for FollowMode {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for FollowMode {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let following = self.peer.is_some();
let held = hold_focus((self.id.clone(), "held"), following, window, cx);
let focus = held.read(cx).focus.clone();
let theme = cx.theme();
let (stop, escape) = (self.on_stop.clone(), self.on_stop);
let released = held.clone();
let color = self.peer.as_ref().map(|peer| peer.color(cx));
div()
.id(self.id.clone())
.track_focus(&focus)
.flex()
.flex_col()
.rounded(theme.radius(Radius::Lg))
.border_2()
.border_color(color.unwrap_or(transparent_black()))
.on_key_down(move |event, window, cx| {
if following && event.keystroke.key == "escape" {
cx.stop_propagation();
log::info!("follow mode: stopped");
hand_back(&held, window, cx);
escape(window, cx);
}
})
.children(self.peer.zip(color).map(|(peer, color)| {
div()
.flex()
.items_center()
.gap_2()
.pl_3()
.pr_1()
.py_1()
.rounded_t(theme.radius(Radius::Md))
.bg(color.opacity(TINT))
.text_size(theme.text_size(TextSize::Sm))
.child(div().size(theme.status_dot()).rounded_full().bg(color))
.child(
div()
.flex_1()
.min_w_0()
.child(format!("Following {}", peer.name)),
)
.child(
Button::new((self.id.clone(), "stop"), "Stop following")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.shortcut("escape")
.on_click(move |_, window, cx| {
log::info!("follow mode: stopped");
hand_back(&released, window, cx);
stop(window, cx)
}),
)
}))
.children(self.body)
}
}