diff --git a/AGENTS.md b/AGENTS.md
index dfef0ab..3bbbed0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -75,7 +75,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. An edit in place that hands focus back keeps its text on Enter's release (`forms::InlineEdit`). The press itself bubbles, so a container that takes Space leaves a focused button its own: `overlays::media_button` stops it.
- A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere.
-- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay.
+- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. Its close runs on every way out, so a dialog with two answers marks the chosen one before it closes (`messaging::IncomingCallDialog`). AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay.
- Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike.
- A component's letter keys step aside while Command or Control is held, so the app's shortcuts reach the app (`media::ImageAnnotator`, `VideoPlayer`, `VideoThumbnailStrip`).
- A tip over a track splits the free width around it by the pointer's share: it stays inside the track and under the pointer, and nothing is measured (`media::scrubber::time_tip`, for the scrubber and the waveform).
@@ -123,7 +123,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model).
- `img()` keeps loading state, and moves an animated picture's frames, only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
- gpui reads a `&str` picture source as a web address. A file goes through `Path` (`documents::source` in the crate, `Path::new` in the gallery).
-- Taffy 0.9's grid rows overgrow tiles that keep a ratio. Tiles in a picture's shape sit in flex rows of `flex_1` cells (`generative::GenerationGrid`).
+- Taffy 0.9's grid rows overgrow tiles that keep a ratio. Tiles in a picture's shape sit in flex rows of `flex_1` cells (`generative::GenerationGrid`). A cell's padding counts in its flex base, so a row of fewer cells gives each more width; cells that match across rows keep their inset on a box inside (`messaging::VideoCallGrid`).
- Rise reads green and fall red unless a component's `red_up` swaps them, as markets in East Asia read. `finance::quotes::moves` picks the pair.
- Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`.
- A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size.
diff --git a/TASKS.md b/TASKS.md
index df32a85..2e19045 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -100,7 +100,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T27d Files · Transfers
- [x] T28a Messaging · Channels
- [x] T28b Messaging · Messages
-- [ ] T28c Messaging · People
+- [x] T28c Messaging · People
- [ ] T28d Messaging · Calls
- [ ] T28e Messaging · Stickers and GIFs
- [ ] T29 Mail
@@ -335,3 +335,4 @@ Per-component lines live in `tasks/`. Tags there:
| T27d | 2 | PASS | |
| T28a | 1 | PASS | |
| T28b | 1 | PASS | |
+| T28c | 1 | PASS | |
diff --git a/assets/icons/headphones.svg b/assets/icons/headphones.svg
new file mode 100644
index 0000000..6414f47
--- /dev/null
+++ b/assets/icons/headphones.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/assets/icons/screen-share-off.svg b/assets/icons/screen-share-off.svg
new file mode 100644
index 0000000..a2c13cd
--- /dev/null
+++ b/assets/icons/screen-share-off.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/assets/icons/screen-share.svg b/assets/icons/screen-share.svg
new file mode 100644
index 0000000..45529e6
--- /dev/null
+++ b/assets/icons/screen-share.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/examples/gallery/pages/messaging/calls.rs b/examples/gallery/pages/messaging/calls.rs
new file mode 100644
index 0000000..83829c5
--- /dev/null
+++ b/examples/gallery/pages/messaging/calls.rs
@@ -0,0 +1,220 @@
+use std::{path::Path, time::Instant};
+
+use ely_gpui_component::{
+ buttons::Button,
+ data_display::Avatar,
+ messaging::{
+ CallControls, HuddleIndicator, IncomingCallDialog, ParticipantTile, ScreenShareView,
+ VideoCallGrid, VoiceCallBar,
+ },
+ theme::{ActiveTheme, TextSize},
+};
+use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
+
+use crate::{
+ probe::probe,
+ ui::{change, keep, section},
+};
+
+const ROOM: &str = asset!("atrium-olive.jpg");
+const STAIR: &str = asset!("atrium-stair.jpg");
+const SCREEN: &str = asset!("frames/screen.jpg");
+/// The demo cameras' frames are the atrium pictures, 3:2.
+const CAMERA: f32 = 1.5;
+
+/// The demo call: your microphone, camera and share, and whether you are in it and since when.
+#[derive(Clone)]
+struct Call {
+ mic: bool,
+ camera: bool,
+ share: bool,
+ joined: Option,
+}
+
+fn call(window: &mut Window, cx: &mut App) -> Entity {
+ keep(
+ "messaging-call",
+ || Call {
+ mic: true,
+ camera: true,
+ share: false,
+ joined: Some(Instant::now()),
+ },
+ window,
+ cx,
+ )
+}
+
+fn huddlers() -> [Avatar; 3] {
+ [
+ Avatar::new("messaging-huddler-ana", "Ana Lima"),
+ Avatar::new("messaging-huddler-ben", "Ben Ito"),
+ Avatar::new("messaging-huddler-chloe", "Chloé Martin"),
+ ]
+}
+
+pub fn controls(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let state = call(window, cx);
+ let now = state.read(cx).clone();
+ let join = state.clone();
+ let huddle = HuddleIndicator::new("messaging-huddle", huddlers()).on_join(move |_, cx| {
+ change(&join, cx, |call| {
+ call.joined.get_or_insert_with(Instant::now);
+ })
+ });
+ let body = match now.joined {
+ Some(since) => {
+ let (mic, bar_mic, camera, share, leave, bar_leave) = (
+ state.clone(),
+ state.clone(),
+ state.clone(),
+ state.clone(),
+ state.clone(),
+ state,
+ );
+ div()
+ .flex()
+ .flex_col()
+ .gap_3()
+ .child(
+ VoiceCallBar::new("messaging-voice", "Huddle in # design", since)
+ .people(huddlers())
+ .mic(now.mic, move |on, _, cx| {
+ change(&bar_mic, cx, |call| call.mic = on)
+ })
+ .on_leave(move |_, cx| change(&bar_leave, cx, |call| call.joined = None)),
+ )
+ .child(
+ div().flex().child(
+ CallControls::new("messaging-controls")
+ .mic(now.mic, move |on, _, cx| {
+ change(&mic, cx, |call| call.mic = on)
+ })
+ .camera(now.camera, move |on, _, cx| {
+ change(&camera, cx, |call| call.camera = on)
+ })
+ .share(now.share, move |on, _, cx| {
+ change(&share, cx, |call| call.share = on)
+ })
+ .on_leave(move |_, cx| change(&leave, cx, |call| call.joined = None)),
+ ),
+ )
+ }
+ None => div()
+ .text_size(cx.theme().text_size(TextSize::Sm))
+ .child("You left the huddle. Join it again from its mark."),
+ };
+ section(
+ "CallControls / VoiceCallBar / HuddleIndicator",
+ "A huddle's mark rings while it is live; a press joins. In the call, a bar tells where it is, how long it has run and who is in it, and the controls turn the microphone, camera and a shared screen on and off, or leave.",
+ cx,
+ )
+ .child(probe(
+ "messaging-call",
+ div()
+ .w(px(560.))
+ .flex()
+ .flex_col()
+ .gap_4()
+ .child(div().flex().child(huddle))
+ .child(body),
+ ))
+}
+
+pub fn grid(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let now = call(window, cx).read(cx).clone();
+ let tile = |key: &'static str, name: &'static str| ParticipantTile::new(key, name, CAMERA);
+ let you = tile("messaging-tile-you", "You").muted(!now.mic);
+ let you = match now.camera {
+ true => you.frame(Path::new(ROOM)),
+ false => you,
+ };
+ section(
+ "ParticipantTile / VideoCallGrid",
+ "Everyone in the call as tiles in even rows, a short last row centered: a camera's frame or the person's initials, their name with a mark when muted, and a ring while they speak. Your tile follows the controls above.",
+ cx,
+ )
+ .child(probe(
+ "messaging-grid",
+ div().w(px(640.)).child(
+ VideoCallGrid::new("messaging-grid")
+ .child(tile("messaging-tile-ana", "Ana Lima").frame(Path::new(STAIR)).speaking(true))
+ .child(tile("messaging-tile-ben", "Ben Ito").muted(true))
+ .child(you)
+ .child(tile("messaging-tile-chloe", "Chloé Martin"))
+ .child(tile("messaging-tile-dev", "Dev Rao").muted(true)),
+ ),
+ ))
+}
+
+pub fn share(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let state = call(window, cx);
+ let sharing = state.read(cx).share;
+ let tile = |key: &'static str, name: &'static str| ParticipantTile::new(key, name, CAMERA);
+ let view = ScreenShareView::new("messaging-share", "Ana Lima", 16.0 / 10.0)
+ .frame(Path::new(SCREEN))
+ .child(tile("messaging-strip-ben", "Ben Ito").muted(true))
+ .child(tile("messaging-strip-chloe", "Chloé Martin").speaking(true));
+ let view = match sharing {
+ true => view.on_stop(move |_, cx| change(&state, cx, |call| call.share = false)),
+ false => view,
+ };
+ section(
+ "ScreenShareView",
+ "A shared screen in its shape under a bar that names who presents, the call's tiles beside it. Share your screen from the controls above, and the bar offers Stop presenting.",
+ cx,
+ )
+ .child(probe("messaging-share", div().w(px(760.)).child(view)))
+}
+
+pub fn incoming(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let ringing = keep("messaging-ringing", || false, window, cx);
+ let answer = keep("messaging-answer", || None::, window, cx);
+ let rings = *ringing.read(cx);
+ let said = answer.read(cx).clone();
+ let ring = ringing.clone();
+ let (accepted, declined) = ((ringing.clone(), answer.clone()), (ringing, answer));
+ let dialog = rings.then(|| {
+ IncomingCallDialog::new(
+ "messaging-incoming",
+ "Ana Lima",
+ move |_, cx| {
+ change(&accepted.0, cx, |ringing| *ringing = false);
+ change(&accepted.1, cx, |answer| {
+ *answer = Some("You accepted Ana's call.".into())
+ });
+ },
+ move |_, cx| {
+ change(&declined.0, cx, |ringing| *ringing = false);
+ change(&declined.1, cx, |answer| {
+ *answer = Some("You declined Ana's call.".into())
+ });
+ },
+ )
+ .video()
+ });
+ let theme = cx.theme();
+ section(
+ "IncomingCallDialog",
+ "A call coming in: the caller ringing, their name and the kind of call, with Decline and Accept. Escape declines.",
+ cx,
+ )
+ .child(probe(
+ "messaging-incoming",
+ div()
+ .flex()
+ .items_center()
+ .gap_4()
+ .child(
+ Button::new("messaging-ring", "Ring")
+ .on_click(move |_, _, cx| change(&ring, cx, |ringing| *ringing = true)),
+ )
+ .children(said.map(|said| {
+ div()
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(theme.colors.fg_muted)
+ .child(said)
+ })),
+ ))
+ .children(dialog)
+}
diff --git a/examples/gallery/pages/messaging/mod.rs b/examples/gallery/pages/messaging/mod.rs
index 64081ec..c25d542 100644
--- a/examples/gallery/pages/messaging/mod.rs
+++ b/examples/gallery/pages/messaging/mod.rs
@@ -7,6 +7,7 @@ use ely_gpui_component::{
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use jiff::{SignedDuration, Timestamp};
+mod calls;
mod messages;
mod people;
@@ -51,6 +52,15 @@ const SCRIPT: &[Step] = &[
Step::Type("On leave till Monday"),
Step::Wait(200),
Step::Shot("status-draft"),
+ Step::DownAt("messaging-call", 73.0, 112.0),
+ Step::UpAt("messaging-call", 73.0, 112.0),
+ Step::HoverAt("messaging-share", 10.0, 10.0),
+ Step::Wait(200),
+ Step::Shot("presenting"),
+ Step::DownAt("messaging-incoming", 24.0, 14.0),
+ Step::UpAt("messaging-incoming", 24.0, 14.0),
+ Step::Wait(300),
+ Step::Shot("incoming-call"),
];
/// The demo's chats: which is open and what waits unread in each.
@@ -238,5 +248,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(people::members(window, cx))
.child(people::statuses(cx))
.child(people::setter(window, cx))
+ .child(calls::controls(window, cx))
+ .child(calls::grid(window, cx))
+ .child(calls::share(window, cx))
+ .child(calls::incoming(window, cx))
.into_any_element()
}
diff --git a/src/media/camera.rs b/src/media/camera.rs
index cf0b480..91713ae 100644
--- a/src/media/camera.rs
+++ b/src/media/camera.rs
@@ -1,7 +1,7 @@
use std::rc::Rc;
use gpui::{
- App, ElementId, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
+ App, Div, ElementId, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, img, prelude::*,
};
@@ -12,6 +12,26 @@ use crate::{
typography::Ellipsis,
};
+/// A name's chip over a frame's lower corner, light on a dark wash.
+pub(crate) fn name_chip(cx: &App) -> Div {
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ div()
+ .absolute()
+ .left_2()
+ .bottom_2()
+ .max_w(gpui::relative(0.8))
+ .flex()
+ .items_center()
+ .gap_1()
+ .px_2()
+ .py_0p5()
+ .rounded(theme.radius(Radius::Sm))
+ .bg(colors.media_backdrop.alpha(0.6))
+ .text_color(colors.on_media)
+ .text_size(theme.text_size(TextSize::Xs))
+}
+
/// What a camera sees, in its own shape, from frames the host hands in; the host mirrors them if it will. With no frame it shows the camera off.
#[derive(IntoElement)]
pub struct CameraPreview {
@@ -58,18 +78,8 @@ impl RenderOnce for CameraPreview {
.rounded(round)
});
let name = self.name.map(|name| {
- div()
+ name_chip(cx)
.debug_selector(|| "camera-name".into())
- .absolute()
- .left_2()
- .bottom_2()
- .max_w(gpui::relative(0.8))
- .px_2()
- .py_0p5()
- .rounded(theme.radius(Radius::Sm))
- .bg(colors.media_backdrop.alpha(0.6))
- .text_color(colors.on_media)
- .text_size(theme.text_size(TextSize::Xs))
.child(Ellipsis::new(name))
});
framed(self.ratio, cx)
diff --git a/src/media/mod.rs b/src/media/mod.rs
index 2f76e8e..e11f599 100644
--- a/src/media/mod.rs
+++ b/src/media/mod.rs
@@ -22,6 +22,7 @@ mod tests;
pub use annotate::ImageAnnotator;
pub use audio::{AudioSpectrum, AudioWaveform};
+pub(crate) use camera::name_chip;
pub use camera::{CameraPreview, Device, DeviceKind, DeviceSelector};
pub use controls::{MediaControls, PlaybackSpeedControl, Repeat, VolumeControl};
pub use crop::Crop;
@@ -30,6 +31,7 @@ pub use listen::{AudioPlayer, Playlist, Track};
pub use marks::{Mark, Tool};
pub use model::{ModelViewer, Orbit};
pub use player::{SPEEDS, VideoPlayer};
+pub(crate) use record::device_toggle;
pub use record::{MicLevelMeter, Recording, ScreenRecorderControls};
pub use scrubber::Scrubber;
pub use strip::VideoThumbnailStrip;
diff --git a/src/media/record.rs b/src/media/record.rs
index 11bcd3d..3e89165 100644
--- a/src/media/record.rs
+++ b/src/media/record.rs
@@ -25,6 +25,26 @@ pub(crate) fn lit(level: f32) -> usize {
(level * SEGMENTS as f32).round() as usize
}
+/// A device toggle, such as a microphone's: its icon shows on or off, and a press flips it, logged under `owner`.
+pub(crate) fn device_toggle(
+ owner: &'static str,
+ id: &ElementId,
+ key: &'static str,
+ icons: (IconName, IconName),
+ tip: &'static str,
+ (on, set): (bool, OnFlag),
+) -> ToggleButton {
+ let item = ToggleItem::new(key)
+ .icon(if on { icons.0 } else { icons.1 })
+ .tooltip(tip);
+ ToggleButton::new((id.clone(), key), item, on)
+ .size(ControlSize::Sm)
+ .on_toggle(move |to, window, cx| {
+ log::info!("{owner}: {key} {}", if to { "on" } else { "off" });
+ set(to, window, cx)
+ })
+}
+
/// Where a screen recording stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Recording {
@@ -177,17 +197,7 @@ impl RenderOnce for ScreenRecorderControls {
icons: (IconName, IconName),
tip: &'static str,
set: Option<(bool, OnFlag)>| {
- set.map(|(on, set)| {
- let item = ToggleItem::new(key)
- .icon(if on { icons.0 } else { icons.1 })
- .tooltip(tip);
- ToggleButton::new((id.clone(), key), item, on)
- .size(ControlSize::Sm)
- .on_toggle(move |to, window, cx| {
- log::info!("screen recorder: {key} {}", if to { "on" } else { "off" });
- set(to, window, cx)
- })
- })
+ set.map(|set| device_toggle("screen recorder", &id, key, icons, tip, set))
};
let devices = self.mic.is_some() || self.camera.is_some();
div()
diff --git a/src/messaging/call.rs b/src/messaging/call.rs
new file mode 100644
index 0000000..0f106ac
--- /dev/null
+++ b/src/messaging/call.rs
@@ -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,
+}
+
+impl CallControls {
+ pub fn new(id: impl Into) -> 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,
+ mic: Option<(bool, OnFlag)>,
+ on_leave: Option,
+}
+
+impl VoiceCallBar {
+ pub fn new(id: impl Into, place: impl Into, 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- ) -> 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,
+ on_join: Option,
+}
+
+impl HuddleIndicator {
+ pub fn new(id: impl Into, people: impl IntoIterator
- ) -> Self {
+ let people: Vec = 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))
+ }
+}
diff --git a/src/messaging/incoming.rs b/src/messaging/incoming.rs
new file mode 100644
index 0000000..e7ce5ec
--- /dev/null
+++ b/src/messaging/incoming.rs
@@ -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,
+ video: bool,
+ on_accept: Run,
+ on_decline: Run,
+}
+
+impl IncomingCallDialog {
+ pub fn new(
+ id: impl Into,
+ caller: impl Into,
+ 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) -> 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)
+ })
+ })
+ }
+}
diff --git a/src/messaging/members.rs b/src/messaging/members.rs
index bb9958e..4a4796d 100644
--- a/src/messaging/members.rs
+++ b/src/messaging/members.rs
@@ -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()])
]
diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs
index d4ff918..a8c618f 100644
--- a/src/messaging/mod.rs
+++ b/src/messaging/mod.rs
@@ -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};
diff --git a/src/messaging/people.rs b/src/messaging/people.rs
index fd72013..303deba 100644
--- a/src/messaging/people.rs
+++ b/src/messaging/people.rs
@@ -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())
diff --git a/src/messaging/status.rs b/src/messaging/status.rs
index 3bb0e64..558a9cf 100644
--- a/src/messaging/status.rs
+++ b/src/messaging/status.rs
@@ -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,
}
}
diff --git a/src/messaging/tests/calls.rs b/src/messaging/tests/calls.rs
new file mode 100644
index 0000000..c16a17a
--- /dev/null
+++ b/src/messaging/tests/calls.rs
@@ -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) -> gpui::AnyElement,
+ asked: Vec,
+}
+
+fn note(owner: &Entity, 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) -> impl IntoElement {
+ div().w(px(480.0)).child((self.part)(cx.entity()))
+ }
+}
+
+fn call(
+ part: fn(Entity) -> gpui::AnyElement,
+ cx: &mut TestAppContext,
+) -> (Entity, &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, cx: &mut gpui::VisualTestContext) -> Vec {
+ 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 {
+ 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) -> impl IntoElement {
+ let (accept, decline) = (cx.entity(), cx.entity());
+ let answer = |owner: Entity, 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, &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"]
+ );
+}
diff --git a/src/messaging/tests/mod.rs b/src/messaging/tests/mod.rs
index 9f5d19f..911cc8e 100644
--- a/src/messaging/tests/mod.rs
+++ b/src/messaging/tests/mod.rs
@@ -9,6 +9,7 @@ use super::{
};
use crate::{data_display::Presence, primitives::FocusNext, theme::Theme};
+mod calls;
mod messages;
mod people;
diff --git a/src/messaging/tests/people.rs b/src/messaging/tests/people.rs
index 7501f8c..1fb9b7d 100644
--- a/src/messaging/tests/people.rs
+++ b/src/messaging/tests/people.rs
@@ -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);
diff --git a/src/messaging/tiles.rs b/src/messaging/tiles.rs
new file mode 100644
index 0000000..7cf08e3
--- /dev/null
+++ b/src/messaging/tiles.rs
@@ -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,
+ picture: Option,
+ muted: bool,
+ speaking: bool,
+}
+
+impl ParticipantTile {
+ /// `ratio` is the call's frame width over its height.
+ pub fn new(id: impl Into, name: impl Into, 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) -> Self {
+ self.frame = Some(source.into());
+ self
+ }
+
+ /// Their picture, for while the camera is off.
+ pub fn picture(mut self, source: impl Into) -> 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) -> Self {
+ Self {
+ id: id.into(),
+ tiles: SmallVec::new(),
+ }
+ }
+}
+
+impl ParentElement for VideoCallGrid {
+ fn extend(&mut self, elements: impl IntoIterator
- ) {
+ 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::);
+ 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 = 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,
+ on_stop: Option,
+ tiles: SmallVec<[AnyElement; 4]>,
+}
+
+impl ScreenShareView {
+ /// `ratio` is the shared screen's width over its height.
+ pub fn new(id: impl Into, presenter: impl Into, 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) -> 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
- ) {
+ 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");
+ }
+ }
+}
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index 6333c7f..0e4fac2 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -169,6 +169,7 @@ icons! {
Heading1 => "heading-1",
Heading2 => "heading-2",
Heading3 => "heading-3",
+ Headphones => "headphones",
Heart => "heart",
History => "history",
Hourglass => "hourglass",
@@ -252,6 +253,8 @@ icons! {
Rows2 => "rows-2",
Save => "save",
Scissors => "scissors",
+ ScreenShare => "screen-share",
+ ScreenShareOff => "screen-share-off",
Search => "search",
SearchX => "search-x",
Send => "send",
diff --git a/src/theme/messaging.rs b/src/theme/messaging.rs
new file mode 100644
index 0000000..6ce3ad0
--- /dev/null
+++ b/src/theme/messaging.rs
@@ -0,0 +1,21 @@
+use gpui::Rems;
+
+use super::{Theme, tokens::px_to_rems};
+
+/// Messaging's measures.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct MessagingSizes {
+ /// A call's strip of tiles beside a shared screen, wide.
+ pub tiles: Rems,
+ /// A call's tile at its narrowest, before a grid drops a column.
+ pub tile: Rems,
+}
+
+impl Theme {
+ pub fn messaging(&self) -> MessagingSizes {
+ MessagingSizes {
+ tiles: px_to_rems(200.0),
+ tile: px_to_rems(160.0),
+ }
+ }
+}
diff --git a/src/theme/mod.rs b/src/theme/mod.rs
index 4809c95..4a50f30 100644
--- a/src/theme/mod.rs
+++ b/src/theme/mod.rs
@@ -2,6 +2,7 @@ mod chart;
mod files;
mod generative;
mod media;
+mod messaging;
mod palette;
mod tokens;
@@ -14,6 +15,7 @@ pub use chart::ChartSizes;
pub use files::FileSizes;
pub use generative::GenerativeSizes;
pub use media::MediaSizes;
+pub use messaging::MessagingSizes;
pub use palette::{Mix, Palette, Syntax};
pub use tokens::{
AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize,
diff --git a/src/typography/formatted.rs b/src/typography/formatted.rs
index 00c2246..a75738f 100644
--- a/src/typography/formatted.rs
+++ b/src/typography/formatted.rs
@@ -181,18 +181,23 @@ impl RelativeTime {
}
}
+/// Redraws the view each tick while `id` stays on screen.
+pub(crate) fn fresh(id: impl Into, window: &mut Window, cx: &mut App) {
+ window.use_keyed_state(id, cx, |window, cx| -> Task<()> {
+ cx.spawn_in(window, async move |ticker, cx| {
+ loop {
+ cx.background_executor().timer(TICK).await;
+ if ticker.update(cx, |_, cx| cx.notify()).is_err() {
+ return;
+ }
+ }
+ })
+ });
+}
+
impl RenderOnce for RelativeTime {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
- window.use_keyed_state(self.id, cx, |window, cx| -> Task<()> {
- cx.spawn_in(window, async move |ticker, cx| {
- loop {
- cx.background_executor().timer(TICK).await;
- if ticker.update(cx, |_, cx| cx.notify()).is_err() {
- return;
- }
- }
- })
- });
+ fresh(self.id, window, cx);
figure(format::relative(self.at, Timestamp::now()))
}
}
diff --git a/src/typography/mod.rs b/src/typography/mod.rs
index 535ffbd..bbc1a2b 100644
--- a/src/typography/mod.rs
+++ b/src/typography/mod.rs
@@ -18,6 +18,7 @@ pub use emoji::Emoji;
pub use fit::{Ellipsis, EllipsisTooltip, MiddleEllipsis};
pub(crate) use fit::{LEADING, text_width};
pub use format::DurationStyle;
+pub(crate) use formatted::fresh;
pub use formatted::{
CurrencyText, DateTimeText, DurationText, FileSizeText, NumberText, PercentText, PluralText,
RelativeTime,
diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md
index 3eeb75d..4881427 100644
--- a/tasks/ch21-30.md
+++ b/tasks/ch21-30.md
@@ -347,13 +347,13 @@
## T28d Messaging · Calls
-- [ ] CallControls
-- [ ] ParticipantTile
-- [ ] VideoCallGrid — host frames
-- [ ] ScreenShareView — host frames
-- [ ] VoiceCallBar
-- [ ] IncomingCallDialog
-- [ ] HuddleIndicator
+- [x] CallControls
+- [x] ParticipantTile
+- [x] VideoCallGrid — host frames
+- [x] ScreenShareView — host frames
+- [x] VoiceCallBar
+- [x] IncomingCallDialog
+- [x] HuddleIndicator
## T28e Messaging · Stickers and GIFs