Files
Ely-GPUI-Components/src/messaging/tiles.rs
T
ZacharyZhang-NYandClaude Opus 5.5 48d02c4903 Messaging · Calls: controls, tiles, a grid, a shared screen, a bar, a ringing dialog, a huddle
CallControls holds the microphone, camera and a shared screen as
toggles, then Leave. ParticipantTile shows a camera's frame from the
host or the person's initials, their name with a mark when muted, and a
ring while they speak. VideoCallGrid lays tiles in even rows as far as
they fit across its measured width; a short last row centers at the
full rows' width. ScreenShareView names who presents and offers Stop
presenting for your own share, the call's tiles beside or below.
VoiceCallBar tells where a call is, how long it has run and who is in
it, with the microphone and Leave. IncomingCallDialog rings with the
caller's picture; Accept or Decline answers each ring once, and Escape
declines. HuddleIndicator rings while a huddle is live; a press joins.

The recorder's device toggle and the camera's name chip now have one
home in media. People: member names sort whatever their case, a
status's words save trimmed, and a profile card's local time ticks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:33:54 -04:00

389 lines
13 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, ImageSource, IntoElement, ParentElement, Pixels, RenderOnce,
SharedString, Styled, Window, canvas, div, img, prelude::*, rems,
};
use smallvec::SmallVec;
use crate::{
buttons::{Button, ButtonVariant},
data_display::Avatar,
forms::Run,
layout::columns_for,
media::name_chip,
primitives::{Icon, IconName, checked_ratio, framed},
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
typography::Ellipsis,
};
/// Someone in a call, in the call's frame shape: their camera's frame from the host, or their initials while it is off; their name in the lower corner with a mark when muted; a ring while they speak.
#[derive(IntoElement)]
pub struct ParticipantTile {
id: ElementId,
name: SharedString,
ratio: f32,
frame: Option<ImageSource>,
picture: Option<ImageSource>,
muted: bool,
speaking: bool,
}
impl ParticipantTile {
/// `ratio` is the call's frame width over its height.
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, ratio: f32) -> Self {
Self {
id: id.into(),
name: name.into(),
ratio: checked_ratio(ratio),
frame: None,
picture: None,
muted: false,
speaking: false,
}
}
/// The camera's frame to show now.
pub fn frame(mut self, source: impl Into<ImageSource>) -> Self {
self.frame = Some(source.into());
self
}
/// Their picture, for while the camera is off.
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
self.picture = Some(source.into());
self
}
pub fn muted(mut self, muted: bool) -> Self {
self.muted = muted;
self
}
pub fn speaking(mut self, speaking: bool) -> Self {
self.speaking = speaking;
self
}
}
impl RenderOnce for ParticipantTile {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let round = theme.radius(Radius::Lg);
let stage = match self.frame {
Some(frame) => img(frame)
.id((self.id.clone(), "frame"))
.size_full()
.rounded(round)
.into_any_element(),
None => {
let avatar = Avatar::new((self.id.clone(), "avatar"), self.name.clone())
.size(AvatarSize::Xl);
let avatar = match self.picture {
Some(picture) => avatar.image(picture),
None => avatar,
};
div()
.absolute()
.inset_0()
.pb_7()
.flex()
.items_center()
.justify_center()
.child(avatar)
.into_any_element()
}
};
let (muted_id, speaking_id) = (self.id.clone(), self.id.clone());
let muted = self.muted.then(|| {
div()
.debug_selector(move || format!("tile-muted {muted_id}"))
.flex_none()
.child(
Icon::new(IconName::MicOff)
.size(IconSize::Xs)
.color(colors.on_media),
)
});
let speaking = self.speaking.then(|| {
div()
.debug_selector(move || format!("tile-speaking {speaking_id}"))
.absolute()
.inset_0()
.rounded(round)
.border_2()
.border_color(colors.success)
});
framed(self.ratio, cx)
.debug_selector(|| "participant-tile".into())
.rounded(round)
.bg(colors.sunken)
.child(stage)
.child(
name_chip(cx)
.children(muted)
.child(div().min_w_0().child(Ellipsis::new(self.name))),
)
.children(speaking)
}
}
/// How many columns hold `count` tiles: the least square that fits them.
fn columns(count: usize) -> usize {
(1..=count)
.find(|columns| columns * columns >= count)
.expect("a call holds a tile")
}
/// Everyone in a call, as tiles in even rows: the columns grow with the count as far as tiles fit across, and a short last row centers at the same width. It holds a tile at least.
#[derive(IntoElement)]
pub struct VideoCallGrid {
id: ElementId,
tiles: SmallVec<[AnyElement; 8]>,
}
impl VideoCallGrid {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
tiles: SmallVec::new(),
}
}
}
impl ParentElement for VideoCallGrid {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.tiles.extend(elements);
}
}
impl RenderOnce for VideoCallGrid {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let width = window.use_keyed_state((self.id.clone(), "width"), cx, |_, _| None::<Pixels>);
let count = self.tiles.len();
let rem = window.rem_size();
let cell = cx.theme().messaging().tile.to_pixels(rem) + rems(0.5).to_pixels(rem);
let fit = match *width.read(cx) {
Some(width) => columns_for(width, cell, Pixels::ZERO) as usize,
None => count,
};
let across = columns(count).min(fit);
let mut tiles = self.tiles.into_iter();
let rows = (0..count.div_ceil(across)).map(|_| {
let row: Vec<AnyElement> = tiles.by_ref().take(across).collect();
let spare = (across - row.len()) as f32 / 2.0;
let spacer = || {
let mut spacer = div().flex_basis(gpui::relative(0.));
spacer.style().flex_grow = Some(spare);
spacer
};
div()
.flex()
.when(spare > 0.0, |line| line.child(spacer()))
.children(
row.into_iter()
.map(|tile| div().flex_1().min_w_0().child(div().p_1().child(tile))),
)
.when(spare > 0.0, |line| line.child(spacer()))
});
let measured = width.clone();
div()
.debug_selector(|| "video-call-grid".into())
.relative()
.w_full()
.flex()
.flex_col()
.children(rows)
.child(
canvas(
move |bounds, window, cx| {
if *measured.read(cx) != Some(bounds.size.width) {
measured.update(cx, |width, cx| {
*width = Some(bounds.size.width);
cx.notify();
});
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
}
}
/// A shared screen in its shape, from frames the host hands in, under a bar that names who presents; your own share, the one with a stop, offers Stop presenting. The call's tiles can run beside it, or below on a narrow window.
#[derive(IntoElement)]
pub struct ScreenShareView {
id: ElementId,
presenter: SharedString,
ratio: f32,
frame: Option<ImageSource>,
on_stop: Option<Run>,
tiles: SmallVec<[AnyElement; 4]>,
}
impl ScreenShareView {
/// `ratio` is the shared screen's width over its height.
pub fn new(id: impl Into<ElementId>, presenter: impl Into<SharedString>, ratio: f32) -> Self {
Self {
id: id.into(),
presenter: presenter.into(),
ratio: checked_ratio(ratio),
frame: None,
on_stop: None,
tiles: SmallVec::new(),
}
}
/// The screen's frame to show now.
pub fn frame(mut self, source: impl Into<ImageSource>) -> Self {
self.frame = Some(source.into());
self
}
/// Offers Stop presenting, for your own share.
pub fn on_stop(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_stop = Some(Rc::new(handler));
self
}
}
impl ParentElement for ScreenShareView {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.tiles.extend(elements);
}
}
impl RenderOnce for ScreenShareView {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let round = theme.radius(Radius::Lg);
let presenter = self.presenter.clone();
let (bar, waiting) = match self.on_stop {
Some(_) => (
"You are presenting".to_string(),
"Waiting for your screen".to_string(),
),
None => (
format!("{} is presenting", self.presenter),
format!("Waiting for {}'s screen", self.presenter),
),
};
let stop = self.on_stop.map(|run| {
Button::new((self.id.clone(), "stop"), "Stop presenting")
.icon(IconName::ScreenShareOff)
.variant(ButtonVariant::Danger)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("screen share {presenter}: stop");
run(window, cx)
})
});
let screen = match self.frame {
Some(frame) => img(frame)
.id((self.id.clone(), "frame"))
.size_full()
.rounded(round)
.into_any_element(),
None => div()
.debug_selector(|| "screen-waiting".into())
.absolute()
.inset_0()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.px_4()
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::ScreenShare)
.size(IconSize::Lg)
.color(colors.fg_subtle),
)
.child(div().max_w_full().child(Ellipsis::new(waiting)))
.into_any_element(),
};
let strip = (!self.tiles.is_empty()).then(|| {
div()
.flex_none()
.w(theme.messaging().tiles)
.flex()
.flex_col()
.gap_2()
.children(self.tiles)
});
div()
.debug_selector(|| "screen-share".into())
.w_full()
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.items_center()
.gap_2()
.child(
Icon::new(IconName::ScreenShare)
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child(Ellipsis::new(bar)),
),
)
.children(stop.map(|stop| div().flex_none().child(stop))),
)
.child(
div()
.flex()
.flex_wrap()
.items_start()
.gap_2()
.child(
div().flex_1().min_w(theme.label_width()).child(
framed(self.ratio, cx)
.rounded(round)
.bg(colors.sunken)
.child(screen),
),
)
.children(strip),
)
}
}
#[cfg(test)]
mod tests {
use super::columns;
#[test]
fn columns_are_the_least_square_that_holds_the_tiles() {
let wanted = [1, 2, 2, 2, 3, 3, 3, 3, 3, 4];
for (count, columns_wanted) in (1..=10).zip(wanted) {
assert_eq!(columns(count), columns_wanted, "{count} tiles");
}
}
}