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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user