From dd465fedb77b63a4c09654349bc888721da16bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 22:33:54 -0400 Subject: [PATCH] =?UTF-8?q?Messaging=20=C2=B7=20Calls:=20controls,=20tiles?= =?UTF-8?q?,=20a=20grid,=20a=20shared=20screen,=20a=20bar,=20a=20ringing?= =?UTF-8?q?=20dialog,=20a=20huddle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 4 +- TASKS.md | 3 +- assets/icons/headphones.svg | 15 + assets/icons/screen-share-off.svg | 19 ++ assets/icons/screen-share.svg | 19 ++ examples/gallery/pages/messaging/calls.rs | 220 ++++++++++++ examples/gallery/pages/messaging/mod.rs | 14 + src/media/camera.rs | 34 +- src/media/mod.rs | 2 + src/media/record.rs | 32 +- src/messaging/call.rs | 328 ++++++++++++++++++ src/messaging/incoming.rs | 112 +++++++ src/messaging/members.rs | 12 +- src/messaging/mod.rs | 6 + src/messaging/people.rs | 7 +- src/messaging/status.rs | 2 +- src/messaging/tests/calls.rs | 298 +++++++++++++++++ src/messaging/tests/mod.rs | 1 + src/messaging/tests/people.rs | 4 +- src/messaging/tiles.rs | 388 ++++++++++++++++++++++ src/primitives/icon.rs | 3 + src/theme/messaging.rs | 21 ++ src/theme/mod.rs | 2 + src/typography/formatted.rs | 25 +- src/typography/mod.rs | 1 + tasks/ch21-30.md | 14 +- 26 files changed, 1535 insertions(+), 51 deletions(-) create mode 100644 assets/icons/headphones.svg create mode 100644 assets/icons/screen-share-off.svg create mode 100644 assets/icons/screen-share.svg create mode 100644 examples/gallery/pages/messaging/calls.rs create mode 100644 src/messaging/call.rs create mode 100644 src/messaging/incoming.rs create mode 100644 src/messaging/tests/calls.rs create mode 100644 src/messaging/tiles.rs create mode 100644 src/theme/messaging.rs 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