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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 22:33:54 -04:00
1 parent dd5e36cd94
commit dd465fedb7
26 files changed
+1535 -51

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
use std::{rc::Rc, time::Instant};
use gpui::{
App, ElementId, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
data_display::{Avatar, AvatarGroup},
feedback::Timer,
forms::{OnFlag, Run},
media::device_toggle,
motion::Pulse,
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
typography::Ellipsis,
};
fn leave(id: &ElementId, owner: &'static str, run: Run) -> Button {
Button::new((id.clone(), "leave"), "Leave")
.icon(IconName::PhoneOff)
.variant(ButtonVariant::Danger)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("{owner}: leave");
run(window, cx)
})
}
/// A call's controls in one pill: the microphone, the camera and a shared screen as toggles, then Leave, each only with its handler.
#[derive(IntoElement)]
pub struct CallControls {
id: ElementId,
mic: Option<(bool, OnFlag)>,
camera: Option<(bool, OnFlag)>,
share: Option<(bool, OnFlag)>,
on_leave: Option<Run>,
}
impl CallControls {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
mic: None,
camera: None,
share: None,
on_leave: None,
}
}
/// Whether the microphone is on, and what gets the change.
pub fn mic(
mut self,
on: bool,
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Self {
self.mic = Some((on, Rc::new(handler)));
self
}
/// Whether the camera is on, and what gets the change.
pub fn camera(
mut self,
on: bool,
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Self {
self.camera = Some((on, Rc::new(handler)));
self
}
/// Whether the screen is shared, and what gets the change.
pub fn share(
mut self,
on: bool,
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Self {
self.share = Some((on, Rc::new(handler)));
self
}
pub fn on_leave(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_leave = Some(Rc::new(handler));
self
}
}
impl RenderOnce for CallControls {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let id = self.id.clone();
let toggle = |key, icons, tip, set: Option<(bool, OnFlag)>| {
set.map(|set| device_toggle("call controls", &id, key, icons, tip, set))
};
let toggles = [
toggle(
"mic",
(IconName::Mic, IconName::MicOff),
"Microphone",
self.mic,
),
toggle(
"camera",
(IconName::Video, IconName::VideoOff),
"Camera",
self.camera,
),
toggle(
"share",
(IconName::ScreenShareOff, IconName::ScreenShare),
"Share screen",
self.share,
),
];
let some = toggles.iter().any(Option::is_some);
let leave = self.on_leave.map(|run| leave(&id, "call controls", run));
div()
.flex_none()
.flex()
.items_center()
.gap_1()
.p_1()
.rounded_full()
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.children(toggles.into_iter().flatten())
.when(some && leave.is_some(), |pill| {
pill.child(
div()
.w_px()
.h(theme.icon_size(IconSize::Md))
.bg(colors.border),
)
})
.children(leave)
}
}
/// A call under way in one bar: a live dot, where it is, how long it has run, who is in it, the microphone and Leave. The bar wraps by the header rule.
#[derive(IntoElement)]
pub struct VoiceCallBar {
id: ElementId,
place: SharedString,
since: Instant,
people: Vec<Avatar>,
mic: Option<(bool, OnFlag)>,
on_leave: Option<Run>,
}
impl VoiceCallBar {
pub fn new(id: impl Into<ElementId>, place: impl Into<SharedString>, since: Instant) -> Self {
Self {
id: id.into(),
place: place.into(),
since,
people: Vec::new(),
mic: None,
on_leave: None,
}
}
/// Who is in the call; the first three show.
pub fn people(mut self, people: impl IntoIterator<Item = Avatar>) -> Self {
self.people = people.into_iter().collect();
self
}
/// Whether the microphone is on, and what gets the change.
pub fn mic(
mut self,
on: bool,
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Self {
self.mic = Some((on, Rc::new(handler)));
self
}
pub fn on_leave(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_leave = Some(Rc::new(handler));
self
}
}
impl RenderOnce for VoiceCallBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let id = self.id.clone();
let dot = div()
.size(theme.status_dot())
.rounded_full()
.bg(colors.success);
let mic = self.mic.map(|set| {
device_toggle(
"voice call",
&id,
"mic",
(IconName::Mic, IconName::MicOff),
"Microphone",
set,
)
});
let people = (!self.people.is_empty())
.then(|| AvatarGroup::new(self.people).max(3).size(AvatarSize::Xs));
div()
.debug_selector(|| "voice-call-bar".into())
.w_full()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.px_3()
.py_2()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_none()
.child(Pulse::new((id.clone(), "live")).child(dot)),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child(Ellipsis::new(self.place)),
)
.child(
div()
.flex_none()
.text_color(colors.fg_muted)
.child(Timer::new((id.clone(), "time"), self.since).size(TextSize::Sm)),
),
)
.child(
div()
.flex_none()
.flex()
.items_center()
.gap_2()
.children(people)
.children(mic)
.children(self.on_leave.map(|run| leave(&id, "voice call", run))),
)
}
}
/// A huddle going on in a chat: headphones, how many are in, and their faces. With a handler a press joins it; its mark rings while live.
#[derive(IntoElement)]
pub struct HuddleIndicator {
id: ElementId,
people: Vec<Avatar>,
on_join: Option<Run>,
}
impl HuddleIndicator {
pub fn new(id: impl Into<ElementId>, people: impl IntoIterator<Item = Avatar>) -> Self {
let people: Vec<Avatar> = people.into_iter().collect();
assert!(!people.is_empty(), "a huddle holds someone");
Self {
id: id.into(),
people,
on_join: None,
}
}
pub fn on_join(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_join = Some(Rc::new(handler));
self
}
}
impl RenderOnce for HuddleIndicator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let count = self.people.len();
let (id, ring) = (self.id.clone(), (self.id.clone(), "ring"));
div()
.id(self.id)
.debug_selector(|| "huddle".into())
.flex_none()
.flex()
.items_center()
.gap_1p5()
.pl_2()
.pr_1()
.py_0p5()
.rounded_full()
.border_1()
.border_color(colors.success.alpha(0.4))
.bg(colors.success_subtle)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.success)
.when_some(self.on_join, |chip, join| {
chip.cursor_pointer()
.tab_index(0)
.focus_ring(cx)
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("huddle {id}: join");
join(window, cx)
})
})
.child(
Pulse::new(ring).child(
Icon::new(IconName::Headphones)
.size(IconSize::Sm)
.color(colors.success),
),
)
.child(count.to_string())
.child(AvatarGroup::new(self.people).max(3).size(AvatarSize::Xs))
}
}
+112
View File
@@ -0,0 +1,112 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
App, ElementId, ImageSource, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
data_display::Avatar,
forms::Run,
motion::Pulse,
overlays::Dialog,
primitives::IconName,
theme::AvatarSize,
};
/// A call coming in: the caller's picture ringing, their name and the kind of call, with Decline and Accept; one of the two answers each ring. Escape declines, a press on the scrim does not. Render it while the call rings.
#[derive(IntoElement)]
pub struct IncomingCallDialog {
id: ElementId,
caller: SharedString,
picture: Option<ImageSource>,
video: bool,
on_accept: Run,
on_decline: Run,
}
impl IncomingCallDialog {
pub fn new(
id: impl Into<ElementId>,
caller: impl Into<SharedString>,
on_accept: impl Fn(&mut Window, &mut App) + 'static,
on_decline: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
caller: caller.into(),
picture: None,
video: false,
on_accept: Rc::new(on_accept),
on_decline: Rc::new(on_decline),
}
}
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
self.picture = Some(source.into());
self
}
/// A video call; a voice call otherwise.
pub fn video(mut self) -> Self {
self.video = true;
self
}
}
impl RenderOnce for IncomingCallDialog {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let avatar =
Avatar::new((self.id.clone(), "avatar"), self.caller.clone()).size(AvatarSize::Xl);
let avatar = match self.picture {
Some(picture) => avatar.image(picture),
None => avatar,
};
let (kind, icon) = if self.video {
("Incoming video call", IconName::Video)
} else {
("Incoming voice call", IconName::Phone)
};
let (id, caller) = (self.id.clone(), self.caller.clone());
let (decline, accept) = (self.on_decline, self.on_accept);
let told = caller.clone();
let (decline_id, accept_id) = (id.clone(), id.clone());
// Every way out runs this close; only Accept answers first.
let answered = Rc::new(Cell::new(false));
let answer = answered.clone();
Dialog::new(self.id, self.caller, move |window, cx| {
if !answered.get() {
log::info!("incoming call {caller}: declined");
decline(window, cx)
}
})
.detail(kind)
.held()
.child(
div()
.debug_selector(|| "incoming-call".into())
.flex()
.justify_center()
.py_4()
.child(Pulse::new((id, "ring")).rounded_full().child(avatar)),
)
.action(move |close| {
Button::new((decline_id, "decline"), "Decline")
.icon(IconName::PhoneOff)
.variant(ButtonVariant::Danger)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |close| {
Button::new((accept_id, "accept"), "Accept")
.icon(icon)
.variant(ButtonVariant::Success)
.on_click(move |_, window, cx| {
log::info!("incoming call {told}: accepted");
answer.set(true);
close(window, cx);
accept(window, cx)
})
})
}
}
+9 -3
View File
@@ -104,14 +104,14 @@ impl MemberList {
}
}
/// The groups `members` fall in, online first, each by name; empty groups drop out.
/// The groups `members` fall in, online first, each by name whatever its case; empty groups drop out.
fn grouped(members: &[Member]) -> Vec<(&'static str, Vec<&Member>)> {
let group = |online: bool| {
let mut group: Vec<&Member> = members
.iter()
.filter(|member| (member.presence != Presence::Offline) == online)
.collect();
group.sort_by(|a, b| a.name.cmp(&b.name));
group.sort_by_key(|member| member.name.to_lowercase());
group
};
[("Online", group(true)), ("Offline", group(false))]
@@ -250,13 +250,19 @@ mod tests {
Member::new("d", "Dev", Presence::Offline),
Member::new("a", "Ana", Presence::Away),
Member::new("b", "Ben", Presence::Online),
Member::new("e", "bea", Presence::Online),
];
assert_eq!(
names(&members),
[
(
"Online",
vec!["Ana".to_string(), "Ben".into(), "Chloé".into()]
vec![
"Ana".to_string(),
"bea".into(),
"Ben".into(),
"Chloé".into()
]
),
("Offline", vec!["Dev".into()])
]
+6
View File
@@ -1,5 +1,7 @@
mod call;
mod channels;
mod header;
mod incoming;
mod members;
mod messages;
mod people;
@@ -7,12 +9,15 @@ mod pins;
mod receipt;
mod status;
mod thread;
mod tiles;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use call::{CallControls, HuddleIndicator, VoiceCallBar};
pub use channels::{ChannelItem, ChannelList, ChatRow, DirectMessageItem};
pub use header::ChannelHeader;
pub use incoming::IncomingCallDialog;
pub use members::{Member, MemberList};
pub use messages::{ChatMessage, UnreadDivider};
pub use people::{OnlineStatus, UserProfileCard};
@@ -20,3 +25,4 @@ pub use pins::{PinnedMessage, PinnedMessages};
pub use receipt::{Delivery, ReadReceipt};
pub use status::{ClearAfter, Status, StatusSetter};
pub use thread::{MessageThread, ThreadPanel};
pub use tiles::{ParticipantTile, ScreenShareView, VideoCallGrid};
+5 -2
View File
@@ -12,7 +12,7 @@ use crate::{
forms::Run,
primitives::{Icon, IconName},
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, TextSize},
typography::{Ellipsis, format},
typography::{Ellipsis, format, fresh},
};
/// Where someone stands: a dot in the presence's color and its word.
@@ -115,7 +115,10 @@ impl UserProfileCard {
}
impl RenderOnce for UserProfileCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.zone.is_some() {
fresh((self.id.clone(), "clock"), window, cx);
}
let theme = cx.theme();
let colors = &theme.colors;
let avatar = Avatar::new((self.id.clone(), "avatar"), self.name.clone())
+1 -1
View File
@@ -88,7 +88,7 @@ impl Draft {
fn status(&self, cx: &App) -> Status {
Status {
emoji: self.emoji.clone(),
text: self.input.read(cx).text().to_string().into(),
text: self.input.read(cx).text().trim().to_string().into(),
clear: self.clear,
}
}
+298
View File
@@ -0,0 +1,298 @@
use std::time::Instant;
use gpui::{
App, Context, Entity, InteractiveElement, IntoElement, Modifiers, ParentElement, Render,
Styled, TestAppContext, Window, div, px,
};
use super::{press, settle, setup, tab_to};
use crate::{
data_display::Avatar,
messaging::{
CallControls, HuddleIndicator, IncomingCallDialog, ParticipantTile, ScreenShareView,
VideoCallGrid, VoiceCallBar,
},
};
/// A view that shows one call part and keeps what it asked.
struct Call {
part: fn(Entity<Call>) -> gpui::AnyElement,
asked: Vec<String>,
}
fn note(owner: &Entity<Call>, what: String, cx: &mut App) {
owner.update(cx, |call, cx| {
call.asked.push(what);
cx.notify();
});
}
impl Render for Call {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().w(px(480.0)).child((self.part)(cx.entity()))
}
}
fn call(
part: fn(Entity<Call>) -> gpui::AnyElement,
cx: &mut TestAppContext,
) -> (Entity<Call>, &mut gpui::VisualTestContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Call {
part,
asked: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(host, cx)
}
fn asked(host: &Entity<Call>, cx: &mut gpui::VisualTestContext) -> Vec<String> {
host.read_with(cx, |call, _| call.asked.clone())
}
#[gpui::test]
fn the_call_controls_each_ask_their_own(cx: &mut TestAppContext) {
let (host, cx) = call(
|owner| {
let (mic, camera, share, leave) = (owner.clone(), owner.clone(), owner.clone(), owner);
CallControls::new("controls")
.mic(true, move |on, _, cx| note(&mic, format!("mic {on}"), cx))
.camera(true, move |on, _, cx| {
note(&camera, format!("camera {on}"), cx)
})
.share(false, move |on, _, cx| {
note(&share, format!("share {on}"), cx)
})
.on_leave(move |_, cx| note(&leave, "leave".into(), cx))
.into_any_element()
},
cx,
);
for nth in 1..=4 {
tab_to(nth, cx);
press("space", cx);
}
assert_eq!(
asked(&host, cx),
["mic false", "camera false", "share true", "leave"]
);
}
#[gpui::test]
fn the_voice_bar_mutes_and_leaves(cx: &mut TestAppContext) {
let (host, cx) = call(
|owner| {
let (mic, leave) = (owner.clone(), owner);
VoiceCallBar::new("voice", "Huddle in # design", Instant::now())
.people([
Avatar::new("ana", "Ana Lima"),
Avatar::new("ben", "Ben Ito"),
])
.mic(true, move |on, _, cx| note(&mic, format!("mic {on}"), cx))
.on_leave(move |_, cx| note(&leave, "leave".into(), cx))
.into_any_element()
},
cx,
);
for nth in 1..=2 {
tab_to(nth, cx);
press("space", cx);
}
assert_eq!(asked(&host, cx), ["mic false", "leave"]);
}
#[gpui::test]
fn a_huddle_joins_on_a_press_or_space(cx: &mut TestAppContext) {
let (host, cx) = call(
|owner| {
HuddleIndicator::new("huddle", [Avatar::new("ana", "Ana Lima")])
.on_join(move |_, cx| note(&owner, "join".into(), cx))
.into_any_element()
},
cx,
);
let chip = cx.debug_bounds("huddle").expect("the huddle draws");
cx.simulate_click(chip.center(), Modifiers::none());
settle(cx);
tab_to(1, cx);
press("space", cx);
assert_eq!(asked(&host, cx), ["join", "join"]);
}
#[gpui::test]
fn a_huddle_without_a_handler_takes_no_tab_stop(cx: &mut TestAppContext) {
let (_, cx) = call(
|_| HuddleIndicator::new("huddle", [Avatar::new("ana", "Ana Lima")]).into_any_element(),
cx,
);
tab_to(1, cx);
assert!(cx.update(|window, cx| window.focused(cx).is_none()));
}
#[test]
#[should_panic(expected = "a huddle holds someone")]
fn a_huddle_holds_someone() {
let _ = HuddleIndicator::new("huddle", Vec::new());
}
#[gpui::test]
fn a_tile_marks_only_its_own_mute_and_speech(cx: &mut TestAppContext) {
let (_, cx) = call(
|_| {
div()
.flex()
.child(
div().w(px(200.0)).child(
ParticipantTile::new("ana", "Ana Lima", 16.0 / 9.0)
.muted(true)
.speaking(true),
),
)
.child(
div()
.w(px(200.0))
.child(ParticipantTile::new("ben", "Ben Ito", 16.0 / 9.0)),
)
.into_any_element()
},
cx,
);
assert!(cx.debug_bounds("tile-muted ana").is_some());
assert!(cx.debug_bounds("tile-speaking ana").is_some());
assert!(cx.debug_bounds("tile-muted ben").is_none());
assert!(cx.debug_bounds("tile-speaking ben").is_none());
}
/// Five tiles, 40 tall, in a grid `wide` across.
fn five(wide: f32) -> gpui::AnyElement {
let tile = |name: &'static str| div().debug_selector(move || name.into()).h(px(40.0));
div()
.w(px(wide))
.child(
VideoCallGrid::new("grid")
.child(tile("t0"))
.child(tile("t1"))
.child(tile("t2"))
.child(tile("t3"))
.child(tile("t4")),
)
.into_any_element()
}
fn at(name: &'static str, cx: &mut gpui::VisualTestContext) -> gpui::Bounds<gpui::Pixels> {
cx.debug_bounds(name).expect("the tile draws")
}
#[gpui::test]
fn a_short_last_row_centers_at_the_full_rows_width(cx: &mut TestAppContext) {
let (_, cx) = call(|_| five(720.0), cx);
let (t0, t2, t3, t4) = (at("t0", cx), at("t2", cx), at("t3", cx), at("t4", cx));
assert_eq!(t0.origin.y, t2.origin.y, "three share the first row");
assert!(t3.origin.y > t0.origin.y, "two drop to the second");
assert!(
(t3.size.width - t0.size.width).abs() < px(0.5),
"{t3:?} {t0:?}"
);
let grid = at("video-call-grid", cx);
let middle = (t3.left() + t4.right()) / 2.0;
assert!(
(middle - grid.center().x).abs() < px(0.5),
"{middle:?} {grid:?}"
);
}
#[gpui::test]
fn a_narrow_grid_keeps_to_the_columns_that_fit(cx: &mut TestAppContext) {
let (_, cx) = call(|_| five(360.0), cx);
let (t0, t1, t2) = (at("t0", cx), at("t1", cx), at("t2", cx));
assert_eq!(t0.origin.y, t1.origin.y, "two fit across");
assert!(t2.origin.y > t1.origin.y, "the third drops");
}
#[gpui::test]
fn a_share_waits_for_its_screen_and_stops_on_ask(cx: &mut TestAppContext) {
let (host, cx) = call(
|owner| {
ScreenShareView::new("share", "Ana Lima", 16.0 / 10.0)
.on_stop(move |_, cx| note(&owner, "stop".into(), cx))
.into_any_element()
},
cx,
);
assert!(cx.debug_bounds("screen-waiting").is_some());
tab_to(1, cx);
press("space", cx);
assert_eq!(asked(&host, cx), ["stop"]);
}
/// A call that rings until it is answered, and the answers.
struct Ringing {
ringing: bool,
asked: Vec<&'static str>,
}
impl Render for Ringing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (accept, decline) = (cx.entity(), cx.entity());
let answer = |owner: Entity<Ringing>, what: &'static str, cx: &mut App| {
owner.update(cx, |ringing, cx| {
ringing.ringing = false;
ringing.asked.push(what);
cx.notify();
})
};
div().size_full().children(self.ringing.then(|| {
IncomingCallDialog::new(
"incoming",
"Ana Lima",
move |_, cx| answer(accept.clone(), "accept", cx),
move |_, cx| answer(decline.clone(), "decline", cx),
)
.video()
}))
}
}
fn ringing(cx: &mut TestAppContext) -> (Entity<Ringing>, &mut gpui::VisualTestContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Ringing {
ringing: true,
asked: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(host, cx)
}
#[gpui::test]
fn accept_answers_once(cx: &mut TestAppContext) {
let (host, cx) = ringing(cx);
tab_to(2, cx);
press("space", cx);
assert_eq!(
host.read_with(cx, |ringing, _| ringing.asked.clone()),
["accept"]
);
}
#[gpui::test]
fn decline_answers_once(cx: &mut TestAppContext) {
let (host, cx) = ringing(cx);
tab_to(1, cx);
press("space", cx);
assert_eq!(
host.read_with(cx, |ringing, _| ringing.asked.clone()),
["decline"]
);
}
#[gpui::test]
fn escape_declines(cx: &mut TestAppContext) {
let (host, cx) = ringing(cx);
press("escape", cx);
assert_eq!(
host.read_with(cx, |ringing, _| ringing.asked.clone()),
["decline"]
);
}
+1
View File
@@ -9,6 +9,7 @@ use super::{
};
use crate::{data_display::Presence, primitives::FocusNext, theme::Theme};
mod calls;
mod messages;
mod people;
+2 -2
View File
@@ -159,10 +159,10 @@ fn a_suggestion_fills_the_status_and_save_hands_it_over(cx: &mut TestAppContext)
}
#[gpui::test]
fn typed_words_save_as_they_stand(cx: &mut TestAppContext) {
fn typed_words_save_trimmed(cx: &mut TestAppContext) {
let (host, cx) = setting(None, cx);
tab_to(2, cx);
cx.simulate_input("Reading");
cx.simulate_input(" Reading ");
settle(cx);
tab_to(5, cx);
press("space", cx);
+388
View File
@@ -0,0 +1,388 @@
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");
}
}
}