Messaging · Calls: controls, tiles, a grid, a shared screen, a bar, a ringing dialog, a huddle
CallControls holds the microphone, camera and a shared screen as toggles, then Leave. ParticipantTile shows a camera's frame from the host or the person's initials, their name with a mark when muted, and a ring while they speak. VideoCallGrid lays tiles in even rows as far as they fit across its measured width; a short last row centers at the full rows' width. ScreenShareView names who presents and offers Stop presenting for your own share, the call's tiles beside or below. VoiceCallBar tells where a call is, how long it has run and who is in it, with the microphone and Leave. IncomingCallDialog rings with the caller's picture; Accept or Decline answers each ring once, and Escape declines. HuddleIndicator rings while a huddle is live; a press joins. The recorder's device toggle and the camera's name chip now have one home in media. People: member names sort whatever their case, a status's words save trimmed, and a profile card's local time ticks.
This commit is contained in:
1 parent
dd5e36cd94
commit
dd465fedb7
26 files changed
+1535
-51
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 | |
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-headphones"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-screen-share-off"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3" />
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
<path d="m22 3-5 5" />
|
||||
<path d="m17 3 5 5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-screen-share"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3" />
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
<path d="m17 8 5-5" />
|
||||
<path d="M17 3h5v5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
fn call(window: &mut Window, cx: &mut App) -> Entity<Call> {
|
||||
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::<SharedString>, 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+22
-12
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
+21
-11
@@ -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()
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
use std::{rc::Rc, time::Instant};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::{Avatar, AvatarGroup},
|
||||
feedback::Timer,
|
||||
forms::{OnFlag, Run},
|
||||
media::device_toggle,
|
||||
motion::Pulse,
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
fn leave(id: &ElementId, owner: &'static str, run: Run) -> Button {
|
||||
Button::new((id.clone(), "leave"), "Leave")
|
||||
.icon(IconName::PhoneOff)
|
||||
.variant(ButtonVariant::Danger)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("{owner}: leave");
|
||||
run(window, cx)
|
||||
})
|
||||
}
|
||||
|
||||
/// A call's controls in one pill: the microphone, the camera and a shared screen as toggles, then Leave, each only with its handler.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CallControls {
|
||||
id: ElementId,
|
||||
mic: Option<(bool, OnFlag)>,
|
||||
camera: Option<(bool, OnFlag)>,
|
||||
share: Option<(bool, OnFlag)>,
|
||||
on_leave: Option<Run>,
|
||||
}
|
||||
|
||||
impl CallControls {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
mic: None,
|
||||
camera: None,
|
||||
share: None,
|
||||
on_leave: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the microphone is on, and what gets the change.
|
||||
pub fn mic(
|
||||
mut self,
|
||||
on: bool,
|
||||
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.mic = Some((on, Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the camera is on, and what gets the change.
|
||||
pub fn camera(
|
||||
mut self,
|
||||
on: bool,
|
||||
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.camera = Some((on, Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the screen is shared, and what gets the change.
|
||||
pub fn share(
|
||||
mut self,
|
||||
on: bool,
|
||||
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.share = Some((on, Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_leave(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_leave = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CallControls {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let id = self.id.clone();
|
||||
let toggle = |key, icons, tip, set: Option<(bool, OnFlag)>| {
|
||||
set.map(|set| device_toggle("call controls", &id, key, icons, tip, set))
|
||||
};
|
||||
let toggles = [
|
||||
toggle(
|
||||
"mic",
|
||||
(IconName::Mic, IconName::MicOff),
|
||||
"Microphone",
|
||||
self.mic,
|
||||
),
|
||||
toggle(
|
||||
"camera",
|
||||
(IconName::Video, IconName::VideoOff),
|
||||
"Camera",
|
||||
self.camera,
|
||||
),
|
||||
toggle(
|
||||
"share",
|
||||
(IconName::ScreenShareOff, IconName::ScreenShare),
|
||||
"Share screen",
|
||||
self.share,
|
||||
),
|
||||
];
|
||||
let some = toggles.iter().any(Option::is_some);
|
||||
let leave = self.on_leave.map(|run| leave(&id, "call controls", run));
|
||||
div()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.p_1()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.children(toggles.into_iter().flatten())
|
||||
.when(some && leave.is_some(), |pill| {
|
||||
pill.child(
|
||||
div()
|
||||
.w_px()
|
||||
.h(theme.icon_size(IconSize::Md))
|
||||
.bg(colors.border),
|
||||
)
|
||||
})
|
||||
.children(leave)
|
||||
}
|
||||
}
|
||||
|
||||
/// A call under way in one bar: a live dot, where it is, how long it has run, who is in it, the microphone and Leave. The bar wraps by the header rule.
|
||||
#[derive(IntoElement)]
|
||||
pub struct VoiceCallBar {
|
||||
id: ElementId,
|
||||
place: SharedString,
|
||||
since: Instant,
|
||||
people: Vec<Avatar>,
|
||||
mic: Option<(bool, OnFlag)>,
|
||||
on_leave: Option<Run>,
|
||||
}
|
||||
|
||||
impl VoiceCallBar {
|
||||
pub fn new(id: impl Into<ElementId>, place: impl Into<SharedString>, since: Instant) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
place: place.into(),
|
||||
since,
|
||||
people: Vec::new(),
|
||||
mic: None,
|
||||
on_leave: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Who is in the call; the first three show.
|
||||
pub fn people(mut self, people: impl IntoIterator<Item = Avatar>) -> Self {
|
||||
self.people = people.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the microphone is on, and what gets the change.
|
||||
pub fn mic(
|
||||
mut self,
|
||||
on: bool,
|
||||
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.mic = Some((on, Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_leave(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_leave = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for VoiceCallBar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let id = self.id.clone();
|
||||
let dot = div()
|
||||
.size(theme.status_dot())
|
||||
.rounded_full()
|
||||
.bg(colors.success);
|
||||
let mic = self.mic.map(|set| {
|
||||
device_toggle(
|
||||
"voice call",
|
||||
&id,
|
||||
"mic",
|
||||
(IconName::Mic, IconName::MicOff),
|
||||
"Microphone",
|
||||
set,
|
||||
)
|
||||
});
|
||||
let people = (!self.people.is_empty())
|
||||
.then(|| AvatarGroup::new(self.people).max(3).size(AvatarSize::Xs));
|
||||
div()
|
||||
.debug_selector(|| "voice-call-bar".into())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(theme.label_width())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.child(Pulse::new((id.clone(), "live")).child(dot)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg)
|
||||
.child(Ellipsis::new(self.place)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(Timer::new((id.clone(), "time"), self.since).size(TextSize::Sm)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.children(people)
|
||||
.children(mic)
|
||||
.children(self.on_leave.map(|run| leave(&id, "voice call", run))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A huddle going on in a chat: headphones, how many are in, and their faces. With a handler a press joins it; its mark rings while live.
|
||||
#[derive(IntoElement)]
|
||||
pub struct HuddleIndicator {
|
||||
id: ElementId,
|
||||
people: Vec<Avatar>,
|
||||
on_join: Option<Run>,
|
||||
}
|
||||
|
||||
impl HuddleIndicator {
|
||||
pub fn new(id: impl Into<ElementId>, people: impl IntoIterator<Item = Avatar>) -> Self {
|
||||
let people: Vec<Avatar> = people.into_iter().collect();
|
||||
assert!(!people.is_empty(), "a huddle holds someone");
|
||||
Self {
|
||||
id: id.into(),
|
||||
people,
|
||||
on_join: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_join(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_join = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for HuddleIndicator {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let count = self.people.len();
|
||||
let (id, ring) = (self.id.clone(), (self.id.clone(), "ring"));
|
||||
div()
|
||||
.id(self.id)
|
||||
.debug_selector(|| "huddle".into())
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.py_0p5()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(colors.success.alpha(0.4))
|
||||
.bg(colors.success_subtle)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.success)
|
||||
.when_some(self.on_join, |chip, join| {
|
||||
chip.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("huddle {id}: join");
|
||||
join(window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Pulse::new(ring).child(
|
||||
Icon::new(IconName::Headphones)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.success),
|
||||
),
|
||||
)
|
||||
.child(count.to_string())
|
||||
.child(AvatarGroup::new(self.people).max(3).size(AvatarSize::Xs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, ImageSource, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::Avatar,
|
||||
forms::Run,
|
||||
motion::Pulse,
|
||||
overlays::Dialog,
|
||||
primitives::IconName,
|
||||
theme::AvatarSize,
|
||||
};
|
||||
|
||||
/// A call coming in: the caller's picture ringing, their name and the kind of call, with Decline and Accept; one of the two answers each ring. Escape declines, a press on the scrim does not. Render it while the call rings.
|
||||
#[derive(IntoElement)]
|
||||
pub struct IncomingCallDialog {
|
||||
id: ElementId,
|
||||
caller: SharedString,
|
||||
picture: Option<ImageSource>,
|
||||
video: bool,
|
||||
on_accept: Run,
|
||||
on_decline: Run,
|
||||
}
|
||||
|
||||
impl IncomingCallDialog {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
caller: impl Into<SharedString>,
|
||||
on_accept: impl Fn(&mut Window, &mut App) + 'static,
|
||||
on_decline: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
caller: caller.into(),
|
||||
picture: None,
|
||||
video: false,
|
||||
on_accept: Rc::new(on_accept),
|
||||
on_decline: Rc::new(on_decline),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
|
||||
self.picture = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// A video call; a voice call otherwise.
|
||||
pub fn video(mut self) -> Self {
|
||||
self.video = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for IncomingCallDialog {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let avatar =
|
||||
Avatar::new((self.id.clone(), "avatar"), self.caller.clone()).size(AvatarSize::Xl);
|
||||
let avatar = match self.picture {
|
||||
Some(picture) => avatar.image(picture),
|
||||
None => avatar,
|
||||
};
|
||||
let (kind, icon) = if self.video {
|
||||
("Incoming video call", IconName::Video)
|
||||
} else {
|
||||
("Incoming voice call", IconName::Phone)
|
||||
};
|
||||
let (id, caller) = (self.id.clone(), self.caller.clone());
|
||||
let (decline, accept) = (self.on_decline, self.on_accept);
|
||||
let told = caller.clone();
|
||||
let (decline_id, accept_id) = (id.clone(), id.clone());
|
||||
// Every way out runs this close; only Accept answers first.
|
||||
let answered = Rc::new(Cell::new(false));
|
||||
let answer = answered.clone();
|
||||
Dialog::new(self.id, self.caller, move |window, cx| {
|
||||
if !answered.get() {
|
||||
log::info!("incoming call {caller}: declined");
|
||||
decline(window, cx)
|
||||
}
|
||||
})
|
||||
.detail(kind)
|
||||
.held()
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "incoming-call".into())
|
||||
.flex()
|
||||
.justify_center()
|
||||
.py_4()
|
||||
.child(Pulse::new((id, "ring")).rounded_full().child(avatar)),
|
||||
)
|
||||
.action(move |close| {
|
||||
Button::new((decline_id, "decline"), "Decline")
|
||||
.icon(IconName::PhoneOff)
|
||||
.variant(ButtonVariant::Danger)
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})
|
||||
.action(move |close| {
|
||||
Button::new((accept_id, "accept"), "Accept")
|
||||
.icon(icon)
|
||||
.variant(ButtonVariant::Success)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("incoming call {told}: accepted");
|
||||
answer.set(true);
|
||||
close(window, cx);
|
||||
accept(window, cx)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()])
|
||||
]
|
||||
|
||||
@@ -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};
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use gpui::{
|
||||
App, Context, Entity, InteractiveElement, IntoElement, Modifiers, ParentElement, Render,
|
||||
Styled, TestAppContext, Window, div, px,
|
||||
};
|
||||
|
||||
use super::{press, settle, setup, tab_to};
|
||||
use crate::{
|
||||
data_display::Avatar,
|
||||
messaging::{
|
||||
CallControls, HuddleIndicator, IncomingCallDialog, ParticipantTile, ScreenShareView,
|
||||
VideoCallGrid, VoiceCallBar,
|
||||
},
|
||||
};
|
||||
|
||||
/// A view that shows one call part and keeps what it asked.
|
||||
struct Call {
|
||||
part: fn(Entity<Call>) -> gpui::AnyElement,
|
||||
asked: Vec<String>,
|
||||
}
|
||||
|
||||
fn note(owner: &Entity<Call>, what: String, cx: &mut App) {
|
||||
owner.update(cx, |call, cx| {
|
||||
call.asked.push(what);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
impl Render for Call {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().w(px(480.0)).child((self.part)(cx.entity()))
|
||||
}
|
||||
}
|
||||
|
||||
fn call(
|
||||
part: fn(Entity<Call>) -> gpui::AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Call>, &mut gpui::VisualTestContext) {
|
||||
setup(cx);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Call {
|
||||
part,
|
||||
asked: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn asked(host: &Entity<Call>, cx: &mut gpui::VisualTestContext) -> Vec<String> {
|
||||
host.read_with(cx, |call, _| call.asked.clone())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_call_controls_each_ask_their_own(cx: &mut TestAppContext) {
|
||||
let (host, cx) = call(
|
||||
|owner| {
|
||||
let (mic, camera, share, leave) = (owner.clone(), owner.clone(), owner.clone(), owner);
|
||||
CallControls::new("controls")
|
||||
.mic(true, move |on, _, cx| note(&mic, format!("mic {on}"), cx))
|
||||
.camera(true, move |on, _, cx| {
|
||||
note(&camera, format!("camera {on}"), cx)
|
||||
})
|
||||
.share(false, move |on, _, cx| {
|
||||
note(&share, format!("share {on}"), cx)
|
||||
})
|
||||
.on_leave(move |_, cx| note(&leave, "leave".into(), cx))
|
||||
.into_any_element()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
for nth in 1..=4 {
|
||||
tab_to(nth, cx);
|
||||
press("space", cx);
|
||||
}
|
||||
assert_eq!(
|
||||
asked(&host, cx),
|
||||
["mic false", "camera false", "share true", "leave"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_voice_bar_mutes_and_leaves(cx: &mut TestAppContext) {
|
||||
let (host, cx) = call(
|
||||
|owner| {
|
||||
let (mic, leave) = (owner.clone(), owner);
|
||||
VoiceCallBar::new("voice", "Huddle in # design", Instant::now())
|
||||
.people([
|
||||
Avatar::new("ana", "Ana Lima"),
|
||||
Avatar::new("ben", "Ben Ito"),
|
||||
])
|
||||
.mic(true, move |on, _, cx| note(&mic, format!("mic {on}"), cx))
|
||||
.on_leave(move |_, cx| note(&leave, "leave".into(), cx))
|
||||
.into_any_element()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
for nth in 1..=2 {
|
||||
tab_to(nth, cx);
|
||||
press("space", cx);
|
||||
}
|
||||
assert_eq!(asked(&host, cx), ["mic false", "leave"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_huddle_joins_on_a_press_or_space(cx: &mut TestAppContext) {
|
||||
let (host, cx) = call(
|
||||
|owner| {
|
||||
HuddleIndicator::new("huddle", [Avatar::new("ana", "Ana Lima")])
|
||||
.on_join(move |_, cx| note(&owner, "join".into(), cx))
|
||||
.into_any_element()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
let chip = cx.debug_bounds("huddle").expect("the huddle draws");
|
||||
cx.simulate_click(chip.center(), Modifiers::none());
|
||||
settle(cx);
|
||||
tab_to(1, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(asked(&host, cx), ["join", "join"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_huddle_without_a_handler_takes_no_tab_stop(cx: &mut TestAppContext) {
|
||||
let (_, cx) = call(
|
||||
|_| HuddleIndicator::new("huddle", [Avatar::new("ana", "Ana Lima")]).into_any_element(),
|
||||
cx,
|
||||
);
|
||||
tab_to(1, cx);
|
||||
assert!(cx.update(|window, cx| window.focused(cx).is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "a huddle holds someone")]
|
||||
fn a_huddle_holds_someone() {
|
||||
let _ = HuddleIndicator::new("huddle", Vec::new());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_tile_marks_only_its_own_mute_and_speech(cx: &mut TestAppContext) {
|
||||
let (_, cx) = call(
|
||||
|_| {
|
||||
div()
|
||||
.flex()
|
||||
.child(
|
||||
div().w(px(200.0)).child(
|
||||
ParticipantTile::new("ana", "Ana Lima", 16.0 / 9.0)
|
||||
.muted(true)
|
||||
.speaking(true),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(200.0))
|
||||
.child(ParticipantTile::new("ben", "Ben Ito", 16.0 / 9.0)),
|
||||
)
|
||||
.into_any_element()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert!(cx.debug_bounds("tile-muted ana").is_some());
|
||||
assert!(cx.debug_bounds("tile-speaking ana").is_some());
|
||||
assert!(cx.debug_bounds("tile-muted ben").is_none());
|
||||
assert!(cx.debug_bounds("tile-speaking ben").is_none());
|
||||
}
|
||||
|
||||
/// Five tiles, 40 tall, in a grid `wide` across.
|
||||
fn five(wide: f32) -> gpui::AnyElement {
|
||||
let tile = |name: &'static str| div().debug_selector(move || name.into()).h(px(40.0));
|
||||
div()
|
||||
.w(px(wide))
|
||||
.child(
|
||||
VideoCallGrid::new("grid")
|
||||
.child(tile("t0"))
|
||||
.child(tile("t1"))
|
||||
.child(tile("t2"))
|
||||
.child(tile("t3"))
|
||||
.child(tile("t4")),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn at(name: &'static str, cx: &mut gpui::VisualTestContext) -> gpui::Bounds<gpui::Pixels> {
|
||||
cx.debug_bounds(name).expect("the tile draws")
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_short_last_row_centers_at_the_full_rows_width(cx: &mut TestAppContext) {
|
||||
let (_, cx) = call(|_| five(720.0), cx);
|
||||
let (t0, t2, t3, t4) = (at("t0", cx), at("t2", cx), at("t3", cx), at("t4", cx));
|
||||
assert_eq!(t0.origin.y, t2.origin.y, "three share the first row");
|
||||
assert!(t3.origin.y > t0.origin.y, "two drop to the second");
|
||||
assert!(
|
||||
(t3.size.width - t0.size.width).abs() < px(0.5),
|
||||
"{t3:?} {t0:?}"
|
||||
);
|
||||
let grid = at("video-call-grid", cx);
|
||||
let middle = (t3.left() + t4.right()) / 2.0;
|
||||
assert!(
|
||||
(middle - grid.center().x).abs() < px(0.5),
|
||||
"{middle:?} {grid:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_narrow_grid_keeps_to_the_columns_that_fit(cx: &mut TestAppContext) {
|
||||
let (_, cx) = call(|_| five(360.0), cx);
|
||||
let (t0, t1, t2) = (at("t0", cx), at("t1", cx), at("t2", cx));
|
||||
assert_eq!(t0.origin.y, t1.origin.y, "two fit across");
|
||||
assert!(t2.origin.y > t1.origin.y, "the third drops");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_share_waits_for_its_screen_and_stops_on_ask(cx: &mut TestAppContext) {
|
||||
let (host, cx) = call(
|
||||
|owner| {
|
||||
ScreenShareView::new("share", "Ana Lima", 16.0 / 10.0)
|
||||
.on_stop(move |_, cx| note(&owner, "stop".into(), cx))
|
||||
.into_any_element()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert!(cx.debug_bounds("screen-waiting").is_some());
|
||||
tab_to(1, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(asked(&host, cx), ["stop"]);
|
||||
}
|
||||
|
||||
/// A call that rings until it is answered, and the answers.
|
||||
struct Ringing {
|
||||
ringing: bool,
|
||||
asked: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl Render for Ringing {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (accept, decline) = (cx.entity(), cx.entity());
|
||||
let answer = |owner: Entity<Ringing>, what: &'static str, cx: &mut App| {
|
||||
owner.update(cx, |ringing, cx| {
|
||||
ringing.ringing = false;
|
||||
ringing.asked.push(what);
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
div().size_full().children(self.ringing.then(|| {
|
||||
IncomingCallDialog::new(
|
||||
"incoming",
|
||||
"Ana Lima",
|
||||
move |_, cx| answer(accept.clone(), "accept", cx),
|
||||
move |_, cx| answer(decline.clone(), "decline", cx),
|
||||
)
|
||||
.video()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn ringing(cx: &mut TestAppContext) -> (Entity<Ringing>, &mut gpui::VisualTestContext) {
|
||||
setup(cx);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Ringing {
|
||||
ringing: true,
|
||||
asked: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn accept_answers_once(cx: &mut TestAppContext) {
|
||||
let (host, cx) = ringing(cx);
|
||||
tab_to(2, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(
|
||||
host.read_with(cx, |ringing, _| ringing.asked.clone()),
|
||||
["accept"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn decline_answers_once(cx: &mut TestAppContext) {
|
||||
let (host, cx) = ringing(cx);
|
||||
tab_to(1, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(
|
||||
host.read_with(cx, |ringing, _| ringing.asked.clone()),
|
||||
["decline"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn escape_declines(cx: &mut TestAppContext) {
|
||||
let (host, cx) = ringing(cx);
|
||||
press("escape", cx);
|
||||
assert_eq!(
|
||||
host.read_with(cx, |ringing, _| ringing.asked.clone()),
|
||||
["decline"]
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use super::{
|
||||
};
|
||||
use crate::{data_display::Presence, primitives::FocusNext, theme::Theme};
|
||||
|
||||
mod calls;
|
||||
mod messages;
|
||||
mod people;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, ImageSource, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||
SharedString, Styled, Window, canvas, div, img, prelude::*, rems,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::Avatar,
|
||||
forms::Run,
|
||||
layout::columns_for,
|
||||
media::name_chip,
|
||||
primitives::{Icon, IconName, checked_ratio, framed},
|
||||
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// Someone in a call, in the call's frame shape: their camera's frame from the host, or their initials while it is off; their name in the lower corner with a mark when muted; a ring while they speak.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ParticipantTile {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
ratio: f32,
|
||||
frame: Option<ImageSource>,
|
||||
picture: Option<ImageSource>,
|
||||
muted: bool,
|
||||
speaking: bool,
|
||||
}
|
||||
|
||||
impl ParticipantTile {
|
||||
/// `ratio` is the call's frame width over its height.
|
||||
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, ratio: f32) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
ratio: checked_ratio(ratio),
|
||||
frame: None,
|
||||
picture: None,
|
||||
muted: false,
|
||||
speaking: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's frame to show now.
|
||||
pub fn frame(mut self, source: impl Into<ImageSource>) -> Self {
|
||||
self.frame = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Their picture, for while the camera is off.
|
||||
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
|
||||
self.picture = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn muted(mut self, muted: bool) -> Self {
|
||||
self.muted = muted;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn speaking(mut self, speaking: bool) -> Self {
|
||||
self.speaking = speaking;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ParticipantTile {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let round = theme.radius(Radius::Lg);
|
||||
let stage = match self.frame {
|
||||
Some(frame) => img(frame)
|
||||
.id((self.id.clone(), "frame"))
|
||||
.size_full()
|
||||
.rounded(round)
|
||||
.into_any_element(),
|
||||
None => {
|
||||
let avatar = Avatar::new((self.id.clone(), "avatar"), self.name.clone())
|
||||
.size(AvatarSize::Xl);
|
||||
let avatar = match self.picture {
|
||||
Some(picture) => avatar.image(picture),
|
||||
None => avatar,
|
||||
};
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.pb_7()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(avatar)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let (muted_id, speaking_id) = (self.id.clone(), self.id.clone());
|
||||
let muted = self.muted.then(|| {
|
||||
div()
|
||||
.debug_selector(move || format!("tile-muted {muted_id}"))
|
||||
.flex_none()
|
||||
.child(
|
||||
Icon::new(IconName::MicOff)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.on_media),
|
||||
)
|
||||
});
|
||||
let speaking = self.speaking.then(|| {
|
||||
div()
|
||||
.debug_selector(move || format!("tile-speaking {speaking_id}"))
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.rounded(round)
|
||||
.border_2()
|
||||
.border_color(colors.success)
|
||||
});
|
||||
framed(self.ratio, cx)
|
||||
.debug_selector(|| "participant-tile".into())
|
||||
.rounded(round)
|
||||
.bg(colors.sunken)
|
||||
.child(stage)
|
||||
.child(
|
||||
name_chip(cx)
|
||||
.children(muted)
|
||||
.child(div().min_w_0().child(Ellipsis::new(self.name))),
|
||||
)
|
||||
.children(speaking)
|
||||
}
|
||||
}
|
||||
|
||||
/// How many columns hold `count` tiles: the least square that fits them.
|
||||
fn columns(count: usize) -> usize {
|
||||
(1..=count)
|
||||
.find(|columns| columns * columns >= count)
|
||||
.expect("a call holds a tile")
|
||||
}
|
||||
|
||||
/// Everyone in a call, as tiles in even rows: the columns grow with the count as far as tiles fit across, and a short last row centers at the same width. It holds a tile at least.
|
||||
#[derive(IntoElement)]
|
||||
pub struct VideoCallGrid {
|
||||
id: ElementId,
|
||||
tiles: SmallVec<[AnyElement; 8]>,
|
||||
}
|
||||
|
||||
impl VideoCallGrid {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
tiles: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for VideoCallGrid {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.tiles.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for VideoCallGrid {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let width = window.use_keyed_state((self.id.clone(), "width"), cx, |_, _| None::<Pixels>);
|
||||
let count = self.tiles.len();
|
||||
let rem = window.rem_size();
|
||||
let cell = cx.theme().messaging().tile.to_pixels(rem) + rems(0.5).to_pixels(rem);
|
||||
let fit = match *width.read(cx) {
|
||||
Some(width) => columns_for(width, cell, Pixels::ZERO) as usize,
|
||||
None => count,
|
||||
};
|
||||
let across = columns(count).min(fit);
|
||||
let mut tiles = self.tiles.into_iter();
|
||||
let rows = (0..count.div_ceil(across)).map(|_| {
|
||||
let row: Vec<AnyElement> = tiles.by_ref().take(across).collect();
|
||||
let spare = (across - row.len()) as f32 / 2.0;
|
||||
let spacer = || {
|
||||
let mut spacer = div().flex_basis(gpui::relative(0.));
|
||||
spacer.style().flex_grow = Some(spare);
|
||||
spacer
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.when(spare > 0.0, |line| line.child(spacer()))
|
||||
.children(
|
||||
row.into_iter()
|
||||
.map(|tile| div().flex_1().min_w_0().child(div().p_1().child(tile))),
|
||||
)
|
||||
.when(spare > 0.0, |line| line.child(spacer()))
|
||||
});
|
||||
let measured = width.clone();
|
||||
div()
|
||||
.debug_selector(|| "video-call-grid".into())
|
||||
.relative()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(rows)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
if *measured.read(cx) != Some(bounds.size.width) {
|
||||
measured.update(cx, |width, cx| {
|
||||
*width = Some(bounds.size.width);
|
||||
cx.notify();
|
||||
});
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared screen in its shape, from frames the host hands in, under a bar that names who presents; your own share, the one with a stop, offers Stop presenting. The call's tiles can run beside it, or below on a narrow window.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ScreenShareView {
|
||||
id: ElementId,
|
||||
presenter: SharedString,
|
||||
ratio: f32,
|
||||
frame: Option<ImageSource>,
|
||||
on_stop: Option<Run>,
|
||||
tiles: SmallVec<[AnyElement; 4]>,
|
||||
}
|
||||
|
||||
impl ScreenShareView {
|
||||
/// `ratio` is the shared screen's width over its height.
|
||||
pub fn new(id: impl Into<ElementId>, presenter: impl Into<SharedString>, ratio: f32) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
presenter: presenter.into(),
|
||||
ratio: checked_ratio(ratio),
|
||||
frame: None,
|
||||
on_stop: None,
|
||||
tiles: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's frame to show now.
|
||||
pub fn frame(mut self, source: impl Into<ImageSource>) -> Self {
|
||||
self.frame = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Offers Stop presenting, for your own share.
|
||||
pub fn on_stop(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_stop = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ScreenShareView {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.tiles.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ScreenShareView {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let round = theme.radius(Radius::Lg);
|
||||
let presenter = self.presenter.clone();
|
||||
let (bar, waiting) = match self.on_stop {
|
||||
Some(_) => (
|
||||
"You are presenting".to_string(),
|
||||
"Waiting for your screen".to_string(),
|
||||
),
|
||||
None => (
|
||||
format!("{} is presenting", self.presenter),
|
||||
format!("Waiting for {}'s screen", self.presenter),
|
||||
),
|
||||
};
|
||||
let stop = self.on_stop.map(|run| {
|
||||
Button::new((self.id.clone(), "stop"), "Stop presenting")
|
||||
.icon(IconName::ScreenShareOff)
|
||||
.variant(ButtonVariant::Danger)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("screen share {presenter}: stop");
|
||||
run(window, cx)
|
||||
})
|
||||
});
|
||||
let screen = match self.frame {
|
||||
Some(frame) => img(frame)
|
||||
.id((self.id.clone(), "frame"))
|
||||
.size_full()
|
||||
.rounded(round)
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.debug_selector(|| "screen-waiting".into())
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.px_4()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::ScreenShare)
|
||||
.size(IconSize::Lg)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(div().max_w_full().child(Ellipsis::new(waiting)))
|
||||
.into_any_element(),
|
||||
};
|
||||
let strip = (!self.tiles.is_empty()).then(|| {
|
||||
div()
|
||||
.flex_none()
|
||||
.w(theme.messaging().tiles)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.children(self.tiles)
|
||||
});
|
||||
div()
|
||||
.debug_selector(|| "screen-share".into())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(theme.label_width())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(IconName::ScreenShare)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg)
|
||||
.child(Ellipsis::new(bar)),
|
||||
),
|
||||
)
|
||||
.children(stop.map(|stop| div().flex_none().child(stop))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w(theme.label_width()).child(
|
||||
framed(self.ratio, cx)
|
||||
.rounded(round)
|
||||
.bg(colors.sunken)
|
||||
.child(screen),
|
||||
),
|
||||
)
|
||||
.children(strip),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::columns;
|
||||
|
||||
#[test]
|
||||
fn columns_are_the_least_square_that_holds_the_tiles() {
|
||||
let wanted = [1, 2, 2, 2, 3, 3, 3, 3, 3, 4];
|
||||
for (count, columns_wanted) in (1..=10).zip(wanted) {
|
||||
assert_eq!(columns(count), columns_wanted, "{count} tiles");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+15
-10
@@ -181,18 +181,23 @@ impl RelativeTime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Redraws the view each tick while `id` stays on screen.
|
||||
pub(crate) fn fresh(id: impl Into<ElementId>, 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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user