Media · Capture and devices: recorder, camera, mic meter, device choice and 3D model viewer

ScreenRecorderControls holds a recording in one pill: Record, then a live
dot and the time it has run beside pause or resume and stop, with mic and
camera toggles. CameraPreview shows host frames in the camera's shape, or
the camera off. MicLevelMeter lights segments up to the level, amber and
red near the top, with tones shared from data_display. DeviceSelector
picks a microphone, camera or speaker through Select, which may now be
empty while shut. ModelViewer shows the host's frame for an orbit that a
drag, the arrows, Command-scroll, + and - and a reset move; the orbit is
seeded from the host so fast drags add up. The gallery draws a stone
torus per orbit and frees each replaced frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 19:05:23 -04:00
1 parent 235f059c8e
commit 7f1cfe8c3d
20 files changed
+1541 -30

No files matched your search

+16 -1
View File
@@ -39,6 +39,11 @@ fn tones(colors: &Palette) -> [Hsla; 3] {
[colors.fg, colors.warning, colors.danger]
}
/// A share's color against its limits: plain, amber past the first, red past the second.
pub(crate) fn tone(share: f32, limits: (f32, f32), colors: &Palette) -> Hsla {
tones(colors)[Level::of(share, thresholds(limits.0, limits.1)) as usize]
}
fn thresholds(warn: f32, danger: f32) -> (f32, f32) {
assert!(
0.0 < warn && warn <= danger && danger <= 1.0,
@@ -411,7 +416,17 @@ impl RenderOnce for UsageBar {
#[cfg(test)]
mod tests {
use super::{Level, UsageBar};
use super::{Level, UsageBar, tone};
use crate::theme::Palette;
#[test]
fn a_tone_follows_the_share_past_each_limit() {
let colors = Palette::light(false);
let limits = (0.8, 0.95);
assert_eq!(tone(0.5, limits, &colors), colors.fg);
assert_eq!(tone(0.85, limits, &colors), colors.warning);
assert_eq!(tone(0.97, limits, &colors), colors.danger);
}
#[test]
fn parts_that_sum_to_the_total_fill_it_despite_float_error() {
+1
View File
@@ -27,6 +27,7 @@ pub use codes::{Barcode, BarcodeError, QrCode, QrError};
pub use comparison::Comparison;
pub use feed::{Activity, ActivityFeed};
pub use gallery::Gallery;
pub(crate) use measures::tone;
pub use measures::{Gauge, Meter, UsageBar};
pub use records::{DescriptionList, PropertyGrid, PropertyGroup};
pub use spark::Sparkline;
+1 -1
View File
@@ -299,7 +299,7 @@ impl Select {
impl RenderOnce for Select {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
assert!(
!self.choices.is_empty(),
self.disabled || !self.choices.is_empty(),
"select {:?} has no choices",
self.id
);
+27
View File
@@ -419,3 +419,30 @@ fn a_free_combobox_keeps_the_text_it_mounts_with(cx: &mut TestAppContext) {
"custom label"
);
}
/// A select with nothing to pick, open or shut.
struct Empty(bool);
impl Render for Empty {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Select::new("empty", [])
.placeholder("Nothing yet")
.disabled(self.0)
}
}
#[gpui::test]
fn a_shut_select_may_be_empty(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Empty(true));
cx.run_until_parked();
cx.update(|window, _| window.focus_next());
assert!(cx.update(|window, cx| window.focused(cx).is_none()));
}
#[gpui::test]
#[should_panic(expected = "has no choices")]
fn an_open_select_with_nothing_to_pick_fails(cx: &mut TestAppContext) {
setup(cx);
cx.add_window_view(|_, _| Empty(false));
}
+1 -1
View File
@@ -5,7 +5,7 @@ mod downloads;
mod embed;
mod grid;
mod hardware;
mod lens;
pub(crate) mod lens;
mod mask;
mod models;
mod playground;
+218
View File
@@ -0,0 +1,218 @@
use std::rc::Rc;
use gpui::{
App, ElementId, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, img, prelude::*,
};
use crate::{
forms::{Choice, OnValue, Select},
primitives::{Icon, IconName, framed},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::Ellipsis,
};
/// 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 {
id: ElementId,
ratio: f32,
frame: Option<ImageSource>,
name: Option<SharedString>,
}
impl CameraPreview {
/// `ratio` is the camera's width over its height.
pub fn new(id: impl Into<ElementId>, ratio: f32) -> Self {
assert!(ratio > 0.0, "a camera's shape of {ratio}");
Self {
id: id.into(),
ratio,
frame: None,
name: None,
}
}
/// The frame to show now.
pub fn frame(mut self, source: impl Into<ImageSource>) -> Self {
self.frame = Some(source.into());
self
}
/// The camera's name, over the frame's lower corner.
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
}
impl RenderOnce for CameraPreview {
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 off = self.frame.is_none();
let frame = self.frame.map(|frame| {
img(frame)
.id((self.id.clone(), "frame"))
.size_full()
.rounded(round)
});
let name = self.name.map(|name| {
div()
.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)
.debug_selector(|| "camera-preview".into())
.rounded(round)
.children(frame)
.when(off, |stage| {
stage.child(
div()
.debug_selector(|| "camera-off".into())
.absolute()
.inset_0()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::VideoOff)
.size(IconSize::Lg)
.color(colors.fg_subtle),
)
.child("Camera off"),
)
})
.children(name)
}
}
/// What a device is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeviceKind {
Microphone,
Camera,
Speaker,
}
impl DeviceKind {
fn icon(self) -> IconName {
match self {
Self::Microphone => IconName::Mic,
Self::Camera => IconName::Video,
Self::Speaker => IconName::Volume2,
}
}
fn noun(self) -> &'static str {
match self {
Self::Microphone => "microphone",
Self::Camera => "camera",
Self::Speaker => "speaker",
}
}
}
/// A device to pick: its key and its name.
#[derive(Clone, Debug, PartialEq)]
pub struct Device {
pub key: SharedString,
pub name: SharedString,
}
/// A choice of microphone, camera or speaker, each with its kind's icon. With none attached it says so and stays shut.
#[derive(IntoElement)]
pub struct DeviceSelector {
id: ElementId,
kind: DeviceKind,
devices: Vec<Device>,
chosen: Option<SharedString>,
on_change: Option<OnValue>,
}
impl DeviceSelector {
pub fn new(
id: impl Into<ElementId>,
kind: DeviceKind,
devices: impl IntoIterator<Item = Device>,
) -> Self {
let devices: Vec<Device> = devices.into_iter().collect();
for (ix, device) in devices.iter().enumerate() {
assert!(
!devices[..ix].iter().any(|other| other.key == device.key),
"device {} twice",
device.key
);
}
Self {
id: id.into(),
kind,
devices,
chosen: None,
on_change: None,
}
}
/// The device in use, by key.
pub fn chosen(mut self, key: impl Into<SharedString>) -> Self {
let key = key.into();
assert!(
self.devices.iter().any(|device| device.key == key),
"no device {key}"
);
self.chosen = Some(key);
self
}
/// Gets the key of the device to use.
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for DeviceSelector {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let (kind, empty) = (self.kind, self.devices.is_empty());
let choices = self
.devices
.into_iter()
.map(|device| Choice::new(device.key, device.name).icon(kind.icon()));
let placeholder = match empty {
true => format!("No {} found", kind.noun()),
false => format!("Choose a {}", kind.noun()),
};
let select = Select::new(self.id, choices)
.placeholder(placeholder)
.disabled(empty || self.on_change.is_none());
let select = match self.chosen {
Some(key) => select.selected(key),
None => select,
};
match self.on_change {
Some(change) => select.on_change(move |key, window, cx| {
log::info!("device selector: {} {key}", kind.noun());
change(key, window, cx)
}),
None => select,
}
}
}
+6
View File
@@ -1,11 +1,14 @@
mod annotate;
mod audio;
mod camera;
mod controls;
mod crop;
mod cropper;
mod listen;
mod marks;
mod model;
mod player;
mod record;
mod scrubber;
mod strip;
mod subtitles;
@@ -19,12 +22,15 @@ mod tests;
pub use annotate::ImageAnnotator;
pub use audio::{AudioSpectrum, AudioWaveform};
pub use camera::{CameraPreview, Device, DeviceKind, DeviceSelector};
pub use controls::{MediaControls, PlaybackSpeedControl, Repeat, VolumeControl};
pub use crop::Crop;
pub use cropper::ImageCropper;
pub use listen::{AudioPlayer, Playlist, Track};
pub use marks::{Mark, Tool};
pub use model::{ModelViewer, Orbit};
pub use player::{SPEEDS, VideoPlayer};
pub use record::{MicLevelMeter, Recording, ScreenRecorderControls};
pub use scrubber::Scrubber;
pub use strip::VideoThumbnailStrip;
pub use subtitles::{Cue, SubtitleEditor};
+253
View File
@@ -0,0 +1,253 @@
use std::rc::Rc;
use gpui::{
App, DragMoveEvent, ElementId, EmptyView, EntityId, ImageSource, InteractiveElement,
IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, StatefulInteractiveElement,
Styled, Window, div, img, prelude::*, transparent_black,
};
use crate::{
generative::lens::{DRAG, START, STEP, turned},
layout::seeded::use_seeded,
motion::Spinner,
overlays::media_button,
primitives::{FocusRing, IconName, framed, tab_stop},
theme::{ActiveTheme, ControlSize, Radius},
};
type OnOrbit = Rc<dyn Fn(Orbit, &mut Window, &mut App)>;
/// Nearest and farthest the camera comes, as shares of the distance that fits the model.
const NEAR: f32 = 0.4;
const FAR: f32 = 4.0;
/// What a key or a button moves the camera nearer or farther by.
const ZOOM: f32 = 1.25;
/// Where the camera sits around a model: turned by yaw and pitch in radians, at a distance that is a share of the one that fits the model in view.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Orbit {
pub yaw: f32,
pub pitch: f32,
pub distance: f32,
}
impl Orbit {
/// The view a model opens at: a little turned, fitted.
pub const START: Self = Self {
yaw: START.0,
pitch: START.1,
distance: 1.0,
};
/// Turned by `by`, the pitch short of either pole.
pub(crate) fn turned(self, by: (f32, f32)) -> Self {
let (yaw, pitch) = turned((self.yaw, self.pitch), by);
Self { yaw, pitch, ..self }
}
/// Nearer below 1, farther above, within reach.
pub(crate) fn scaled(self, by: f32) -> Self {
Self {
distance: (self.distance * by).clamp(NEAR, FAR),
..self
}
}
}
/// An orbiting drag, marked with its viewer.
struct Orbiting(EntityId);
/// A 3D model the host renders, a frame for each view, in the view's shape. A drag or the arrows turn the camera around it; Command-scroll, + and - bring it nearer or farther; 0 or the reset button returns to the start. Until its first frame, it shows a spinner.
#[derive(IntoElement)]
pub struct ModelViewer {
id: ElementId,
ratio: f32,
frame: Option<ImageSource>,
orbit: Orbit,
on_orbit: Option<OnOrbit>,
}
impl ModelViewer {
/// `ratio` is the view's width over its height.
pub fn new(id: impl Into<ElementId>, ratio: f32, orbit: Orbit) -> Self {
assert!(ratio > 0.0, "a view's shape of {ratio}");
assert!(
(NEAR..=FAR).contains(&orbit.distance),
"a distance of {}",
orbit.distance
);
Self {
id: id.into(),
ratio,
frame: None,
orbit,
on_orbit: None,
}
}
/// The frame for the orbit now.
pub fn frame(mut self, source: impl Into<ImageSource>) -> Self {
self.frame = Some(source.into());
self
}
/// Gets the orbit to draw next.
pub fn on_orbit(mut self, handler: impl Fn(Orbit, &mut Window, &mut App) + 'static) -> Self {
self.on_orbit = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ModelViewer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let orbit = use_seeded((self.id.clone(), "orbit"), self.orbit, window, cx);
let from = window.use_keyed_state((self.id.clone(), "from"), cx, |_, _| {
Point::<Pixels>::default()
});
let focus = tab_stop(
(self.id.clone(), "focus").into(),
self.on_orbit.is_some(),
window,
cx,
);
let owner = orbit.entity_id();
let theme = cx.theme();
let colors = &theme.colors;
let round = theme.radius(Radius::Lg);
let body = match self.frame {
Some(frame) => img(frame)
.id((self.id.clone(), "frame"))
.size_full()
.rounded(round)
.into_any_element(),
None => div()
.debug_selector(|| "model-loading".into())
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.child(Spinner::new((self.id.clone(), "loading")))
.into_any_element(),
};
let stage = framed(self.ratio, cx)
.id(self.id.clone())
.debug_selector(|| "model-viewer".into())
.rounded(round)
.border_1()
.border_color(transparent_black())
.child(body);
let Some(send) = self.on_orbit else {
return stage;
};
let move_to = {
let (orbit, send) = (orbit.clone(), send.clone());
Rc::new(move |next: Orbit, window: &mut Window, cx: &mut App| {
orbit.update(cx, |orbit, _| orbit.value = next);
send(next, window, cx)
})
};
let now = orbit.read(cx).value;
let button = |key: &'static str, icon: IconName, next: Orbit| {
let move_to = move_to.clone();
media_button(
(self.id.clone(), key),
icon,
ControlSize::Sm,
next != now,
move |window, cx| {
log::info!("model viewer: {key}");
move_to(next, window, cx)
},
cx,
)
};
let buttons = div()
.absolute()
.right_2()
.bottom_2()
.flex()
.items_center()
.rounded_full()
.bg(colors.media_backdrop.alpha(0.6))
.child(button("farther", IconName::Minus, now.scaled(ZOOM)))
.child(button("nearer", IconName::Plus, now.scaled(1.0 / ZOOM)))
.child(button("reset", IconName::RotateCcw, Orbit::START));
let (pressed, dragged, wheeled, keyed) =
(from.clone(), move_to.clone(), move_to.clone(), move_to);
let (spun, zoomed, turned_by_key) = (orbit.clone(), orbit.clone(), orbit);
stage
.track_focus(&focus)
.focus_ring(cx)
.cursor_grab()
.child(buttons)
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
pressed.update(cx, |from, _| *from = event.position)
})
.on_drag(Orbiting(owner), |_, _, _, cx| {
log::info!("model viewer: a drag turns the view");
cx.new(|_| EmptyView)
})
.on_drag_move(move |event: &DragMoveEvent<Orbiting>, window, cx| {
if event.drag(cx).0 != owner {
return;
}
let delta = event.event.position - *from.read(cx);
from.update(cx, |from, _| *from = event.event.position);
let by = (f32::from(delta.x) * DRAG, f32::from(delta.y) * DRAG);
let next = spun.read(cx).value.turned(by);
dragged(next, window, cx)
})
.on_scroll_wheel(move |event, window, cx| {
if !(event.modifiers.platform || event.modifiers.control) {
return;
}
cx.stop_propagation();
let delta = event.delta.pixel_delta(window.line_height());
let next = zoomed
.read(cx)
.value
.scaled((f32::from(delta.y) * 0.01).exp());
wheeled(next, window, cx)
})
.on_key_down(move |event, window, cx| {
let held = &event.keystroke.modifiers;
if held.platform || held.control {
return;
}
let now = turned_by_key.read(cx).value;
let next = match event.keystroke.key.as_str() {
"left" => now.turned((-STEP, 0.0)),
"right" => now.turned((STEP, 0.0)),
"up" => now.turned((0.0, -STEP)),
"down" => now.turned((0.0, STEP)),
"+" | "=" => now.scaled(1.0 / ZOOM),
"-" => now.scaled(ZOOM),
"0" => Orbit::START,
_ => return,
};
cx.stop_propagation();
log::info!(
"model viewer: turned to {:.2}, {:.2} at {:.2}",
next.yaw,
next.pitch,
next.distance
);
keyed(next, window, cx)
})
}
}
#[cfg(test)]
mod tests {
use super::{FAR, NEAR, Orbit};
#[test]
fn distance_stays_within_reach_and_a_turn_keeps_it() {
assert_eq!(Orbit::START.scaled(100.0).distance, FAR);
assert_eq!(Orbit::START.scaled(0.01).distance, NEAR);
let turned = Orbit::START.scaled(2.0).turned((0.5, 0.0));
assert_eq!(turned.distance, 2.0);
assert!((turned.yaw - (Orbit::START.yaw + 0.5)).abs() < 1e-6);
}
}
+276
View File
@@ -0,0 +1,276 @@
use std::{rc::Rc, time::Instant};
use gpui::{
AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div,
prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem},
data_display::tone,
feedback::Timer,
forms::{OnFlag, Run},
motion::Pulse,
primitives::IconName,
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// Where a mic meter turns amber, then red.
const LOUD: (f32, f32) = (0.8, 0.95);
/// Segments along a mic meter.
const SEGMENTS: usize = 24;
/// How many of a mic meter's segments a level lights.
pub(crate) fn lit(level: f32) -> usize {
(level * SEGMENTS as f32).round() as usize
}
/// Where a screen recording stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Recording {
Idle,
/// Running, counted from `since`; a resume moves `since` on by the pause.
Running {
since: Instant,
},
/// Paused at `at`, counted from `since`.
Paused {
since: Instant,
at: Instant,
},
}
/// A screen recording's controls in one pill. Idle, Record starts it; running, a live dot and the time it has run sit before pause and stop; paused, the time holds and resume takes pause's place. Mic and camera toggles follow, each shown with its handler.
#[derive(IntoElement)]
pub struct ScreenRecorderControls {
id: ElementId,
state: Recording,
mic: Option<(bool, OnFlag)>,
camera: Option<(bool, OnFlag)>,
on_record: Option<Run>,
on_pause: Option<Run>,
on_resume: Option<Run>,
on_stop: Option<Run>,
}
impl ScreenRecorderControls {
pub fn new(id: impl Into<ElementId>, state: Recording) -> Self {
if let Recording::Paused { since, at } = state {
assert!(since <= at, "a pause before its recording started");
}
Self {
id: id.into(),
state,
mic: None,
camera: None,
on_record: None,
on_pause: None,
on_resume: None,
on_stop: None,
}
}
pub fn on_record(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_record = Some(Rc::new(handler));
self
}
pub fn on_pause(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_pause = Some(Rc::new(handler));
self
}
pub fn on_resume(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_resume = Some(Rc::new(handler));
self
}
pub fn on_stop(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_stop = Some(Rc::new(handler));
self
}
/// Whether the microphone records too, 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 records too, 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
}
}
impl RenderOnce for ScreenRecorderControls {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let id = self.id.clone();
let action = |key: &'static str, icon: IconName, tip: &'static str, run: Option<Run>| {
run.map(|run| {
IconButton::new((id.clone(), key), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip)
.on_click(move |_, window, cx| {
log::info!("screen recorder: {key}");
run(window, cx)
})
})
};
let dot = |live: bool| -> AnyElement {
let color = if live { colors.danger } else { colors.fg_muted };
let dot = div().size(theme.status_dot()).rounded_full().bg(color);
match live {
true => Pulse::new((id.clone(), "live"))
.child(dot)
.into_any_element(),
false => dot.into_any_element(),
}
};
let time = |since: Instant, at: Option<Instant>| {
Timer::new((id.clone(), "time"), since)
.stopped(at)
.size(TextSize::Sm)
};
let body = match self.state {
Recording::Idle => div().children(self.on_record.map(|run| {
Button::new((id.clone(), "record"), "Record")
.icon(IconName::CircleDot)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("screen recorder: record");
run(window, cx)
})
})),
Recording::Running { since } => div()
.flex()
.items_center()
.gap_2()
.pl_2()
.child(dot(true))
.child(time(since, None))
.children(action("pause", IconName::Pause, "Pause", self.on_pause))
.children(action("stop", IconName::Square, "Stop", self.on_stop)),
Recording::Paused { since, at } => div()
.flex()
.items_center()
.gap_2()
.pl_2()
.child(dot(false))
.child(time(since, Some(at)))
.children(action("resume", IconName::Play, "Resume", self.on_resume))
.children(action("stop", IconName::Square, "Stop", self.on_stop)),
};
let toggle = |key: &'static str,
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)
})
})
};
let devices = self.mic.is_some() || self.camera.is_some();
div()
.flex_none()
.flex()
.items_center()
.gap_1()
.p_1()
.rounded_full()
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(body)
.when(devices, |pill| {
pill.child(
div()
.w_px()
.h(theme.icon_size(IconSize::Md))
.bg(colors.border),
)
})
.children(toggle(
"mic",
(IconName::Mic, IconName::MicOff),
"Microphone",
self.mic,
))
.children(toggle(
"camera",
(IconName::Video, IconName::VideoOff),
"Camera",
self.camera,
))
}
}
/// A microphone's level as it comes in, 0 to 1: segments lit up to it, amber near the top and red at the top. The host sends levels as they come.
#[derive(IntoElement)]
pub struct MicLevelMeter {
level: f32,
}
impl MicLevelMeter {
pub fn new(level: f32) -> Self {
assert!((0.0..=1.0).contains(&level), "a level of {level}");
Self { level }
}
}
impl RenderOnce for MicLevelMeter {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let lit = lit(self.level);
let (unlit, round) = (colors.border, theme.radius(Radius::Sm));
div()
.debug_selector(|| "mic-level".into())
.w_full()
.h(theme.meter_track())
.flex()
.gap_0p5()
.children((0..SEGMENTS).map(|ix| {
let share = (ix + 1) as f32 / SEGMENTS as f32;
let color = if ix < lit {
tone(share, LOUD, colors)
} else {
unlit
};
div().flex_1().h_full().rounded(round).bg(color)
}))
}
}
#[cfg(test)]
mod tests {
use super::{SEGMENTS, lit};
#[test]
fn a_level_lights_the_segments_up_to_it() {
assert_eq!(lit(0.0), 0);
assert_eq!(lit(0.02), 0);
assert_eq!(lit(0.03), 1);
assert_eq!(lit(0.5), SEGMENTS / 2);
assert_eq!(lit(1.0), SEGMENTS);
}
}
+1 -12
View File
@@ -5,7 +5,7 @@ use gpui::{
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{across, press, settle, setup};
use super::{across, press, settle, setup, tab_to};
use crate::media::{
AudioPlayer, AudioWaveform, MediaControls, PlaybackSpeedControl, Playlist, Repeat, Track,
VolumeControl,
@@ -13,17 +13,6 @@ use crate::media::{
const SECOND: Duration = Duration::from_secs(1);
/// Moves focus to the `nth` Tab stop from none.
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
for _ in 0..nth {
window.focus_next();
}
});
settle(cx);
}
/// A waveform of a hundred seconds, 400 wide, and the times it was sent to.
struct Waving {
at: Duration,
+310
View File
@@ -0,0 +1,310 @@
use std::time::{Duration, Instant};
use gpui::{
Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Render, ScrollDelta,
ScrollWheelEvent, SharedString, Styled, TestAppContext, TouchPhase, VisualTestContext, Window,
div, point, px,
};
use super::{picture, press, settle, setup, tab_to};
use crate::media::{
CameraPreview, Device, DeviceKind, DeviceSelector, ModelViewer, Orbit, Recording,
ScreenRecorderControls,
};
/// A recorder, where it stands, whether it takes the mic, and what it was told.
struct Taping {
state: Recording,
mic: bool,
heard: Vec<&'static str>,
}
impl Render for Taping {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let hear = |what: &'static str, cx: &mut Context<Self>| {
let owner = cx.entity();
move |_: &mut Window, cx: &mut gpui::App| {
owner.update(cx, |view, _| view.heard.push(what))
}
};
let mic = cx.entity();
div().child(
ScreenRecorderControls::new("rec", self.state)
.on_record(hear("record", cx))
.on_pause(hear("pause", cx))
.on_resume(hear("resume", cx))
.on_stop(hear("stop", cx))
.mic(self.mic, move |on, _, cx| {
mic.update(cx, |view, cx| {
view.mic = on;
cx.notify();
})
}),
)
}
}
fn now_at(state: Recording, view: &Entity<Taping>, cx: &mut VisualTestContext) {
view.update(cx, |view, cx| {
view.state = state;
cx.notify();
});
settle(cx);
}
#[gpui::test]
fn each_state_offers_its_own_buttons(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Taping {
state: Recording::Idle,
mic: true,
heard: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
press("space", cx);
let since = Instant::now();
now_at(Recording::Running { since }, &view, cx);
for nth in [1, 2, 3] {
tab_to(nth, cx);
press("space", cx);
}
let at = since + Duration::from_secs(4);
now_at(Recording::Paused { since, at }, &view, cx);
tab_to(1, cx);
press("space", cx);
let heard = view.read_with(cx, |view, _| (view.heard.clone(), view.mic));
assert_eq!(heard, (vec!["record", "pause", "stop", "resume"], false));
}
#[test]
#[should_panic(expected = "a pause before its recording started")]
fn a_pause_comes_after_its_start() {
let at = Instant::now();
let since = at + Duration::from_secs(1);
let _ = ScreenRecorderControls::new("rec", Recording::Paused { since, at });
}
/// A preview this wide, with a frame or not, under a long name.
struct Watching {
width: f32,
frame: Option<std::path::PathBuf>,
}
impl Render for Watching {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let preview =
CameraPreview::new("cam", 1.5).name("The camera over the long desk by the window");
let preview = match self.frame.clone() {
Some(path) => preview.frame(path),
None => preview,
};
div().w(px(self.width)).child(preview)
}
}
#[gpui::test]
fn with_no_frame_the_camera_shows_off(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Watching {
width: 300.0,
frame: None,
});
settle(cx);
assert!(cx.debug_bounds("camera-off").is_some());
}
#[gpui::test]
fn a_framed_camera_names_itself_inside_a_narrow_box(cx: &mut TestAppContext) {
setup(cx);
let frame = Some(picture("camera", 150, 100));
let (_, cx) = cx.add_window_view(|_, _| Watching {
width: 200.0,
frame,
});
settle(cx);
assert!(cx.debug_bounds("camera-off").is_none(), "a frame shows");
let (name, preview) = (
cx.debug_bounds("camera-name").expect("the name draws"),
cx.debug_bounds("camera-preview")
.expect("the preview draws"),
);
assert!(name.right() <= preview.right(), "{name:?} in {preview:?}");
}
fn device(key: &str) -> Device {
Device {
key: SharedString::from(key.to_string()),
name: SharedString::from(format!("Device {key}")),
}
}
/// Two microphones, the first in use, and the ones it was told to use.
struct Choosing {
devices: Vec<Device>,
heard: Vec<SharedString>,
}
impl Render for Choosing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
let selector = DeviceSelector::new("mics", DeviceKind::Microphone, self.devices.clone());
let selector = match self.devices.first() {
Some(first) => selector.chosen(first.key.clone()),
None => selector,
};
div().w(px(280.0)).h(px(300.0)).child(
selector.on_change(move |key, _, cx| {
owner.update(cx, |view, _| view.heard.push(key.clone()))
}),
)
}
}
#[gpui::test]
fn down_and_enter_choose_the_next_device(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Choosing {
devices: vec![device("a"), device("b")],
heard: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
press("enter", cx);
press("down", cx);
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.heard.clone()), ["b"]);
}
#[gpui::test]
fn with_none_attached_the_selector_takes_no_focus(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Choosing {
devices: Vec::new(),
heard: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
assert!(cx.update(|window, cx| window.focused(cx).is_none()));
}
#[test]
#[should_panic(expected = "no device c")]
fn the_device_in_use_is_one_of_the_list() {
let _ = DeviceSelector::new("mics", DeviceKind::Microphone, [device("a")]).chosen("c");
}
#[test]
#[should_panic(expected = "device a twice")]
fn a_device_is_listed_once() {
let _ = DeviceSelector::new("mics", DeviceKind::Speaker, [device("a"), device("a")]);
}
/// A model viewer, its host's orbit, and every orbit it was told.
struct Orbiting {
orbit: Orbit,
heard: Vec<Orbit>,
framed: bool,
}
impl Render for Orbiting {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
let viewer = ModelViewer::new("model", 1.5, self.orbit).on_orbit(move |orbit, _, cx| {
owner.update(cx, |view, cx| {
view.orbit = orbit;
view.heard.push(orbit);
cx.notify();
})
});
let viewer = match self.framed {
true => viewer.frame(picture("model", 150, 100)),
false => viewer,
};
div().w(px(480.0)).child(viewer)
}
}
fn orbiting(framed: bool, cx: &mut TestAppContext) -> (Entity<Orbiting>, &mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(move |_, _| Orbiting {
orbit: Orbit::START,
heard: Vec::new(),
framed,
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
fn orbit(view: &Entity<Orbiting>, cx: &mut VisualTestContext) -> Orbit {
view.read_with(cx, |view, _| view.orbit)
}
#[gpui::test]
fn a_drag_turns_the_view_by_its_whole_length(cx: &mut TestAppContext) {
let (view, cx) = orbiting(true, cx);
let middle = cx
.debug_bounds("model-viewer")
.expect("the viewer draws")
.center();
let none = Modifiers::none();
cx.simulate_mouse_down(middle, MouseButton::Left, none);
cx.simulate_mouse_move(middle + point(px(20.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_move(middle + point(px(100.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_up(middle + point(px(100.0), px(0.0)), MouseButton::Left, none);
settle(cx);
let now = orbit(&view, cx);
assert!((now.yaw - (Orbit::START.yaw + 1.0)).abs() < 1e-4, "{now:?}");
assert_eq!((now.pitch, now.distance), (Orbit::START.pitch, 1.0));
}
#[gpui::test]
fn keys_turn_bring_nearer_and_return_but_leave_command_keys(cx: &mut TestAppContext) {
let (view, cx) = orbiting(true, cx);
tab_to(1, cx);
press("right", cx);
assert!((orbit(&view, cx).yaw - (Orbit::START.yaw + std::f32::consts::PI / 12.0)).abs() < 1e-5);
press("=", cx);
assert!((orbit(&view, cx).distance - 0.8).abs() < 1e-5);
press("cmd-=", cx);
assert!(
(orbit(&view, cx).distance - 0.8).abs() < 1e-5,
"a command key passes"
);
press("0", cx);
assert_eq!(orbit(&view, cx), Orbit::START);
}
#[gpui::test]
fn command_scroll_brings_it_nearer_and_a_plain_scroll_passes(cx: &mut TestAppContext) {
let (view, cx) = orbiting(true, cx);
let middle = cx
.debug_bounds("model-viewer")
.expect("the viewer draws")
.center();
let wheel = |platform: bool| ScrollWheelEvent {
position: middle,
delta: ScrollDelta::Pixels(point(px(0.0), px(-50.0))),
modifiers: Modifiers {
platform,
..Modifiers::none()
},
touch_phase: TouchPhase::Moved,
};
cx.simulate_event(wheel(false));
settle(cx);
assert_eq!(orbit(&view, cx), Orbit::START);
cx.simulate_event(wheel(true));
settle(cx);
assert!((orbit(&view, cx).distance - (-0.5f32).exp()).abs() < 1e-4);
}
#[gpui::test]
fn until_its_first_frame_it_spins(cx: &mut TestAppContext) {
let (_, cx) = orbiting(false, cx);
assert!(cx.debug_bounds("model-loading").is_some());
}
+12
View File
@@ -7,6 +7,7 @@ use gpui::{
use crate::{primitives::FocusNext, theme::Theme};
mod audio;
mod devices;
mod edit;
mod images;
mod player;
@@ -55,3 +56,14 @@ pub(super) fn across(
let bounds = cx.debug_bounds(selector).expect("the box draws");
point(bounds.left() + bounds.size.width * share, bounds.center().y)
}
/// Moves focus to the `nth` Tab stop from none.
pub(super) fn tab_to(nth: usize, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
for _ in 0..nth {
window.focus_next();
}
});
settle(cx);
}