From d1b8ad395dcae556f714c5a1e08c797585333f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 19:05:23 -0400 Subject: [PATCH] =?UTF-8?q?Media=20=C2=B7=20Capture=20and=20devices:=20rec?= =?UTF-8?q?order,=20camera,=20mic=20meter,=20device=20choice=20and=203D=20?= =?UTF-8?q?model=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 2 + TASKS.md | 3 +- examples/gallery/pages/media/audio.rs | 10 +- examples/gallery/pages/media/devices.rs | 244 +++++++++++++++++++ examples/gallery/pages/media/mod.rs | 22 ++ examples/gallery/pages/media/sculpt.rs | 135 +++++++++++ examples/gallery/ui.rs | 8 + src/data_display/measures.rs | 17 +- src/data_display/mod.rs | 1 + src/forms/select.rs | 2 +- src/forms/tests/choices.rs | 27 +++ src/generative/mod.rs | 2 +- src/media/camera.rs | 218 +++++++++++++++++ src/media/mod.rs | 6 + src/media/model.rs | 253 +++++++++++++++++++ src/media/record.rs | 276 +++++++++++++++++++++ src/media/tests/audio.rs | 13 +- src/media/tests/devices.rs | 310 ++++++++++++++++++++++++ src/media/tests/mod.rs | 12 + tasks/ch21-30.md | 10 +- 20 files changed, 1541 insertions(+), 30 deletions(-) create mode 100644 examples/gallery/pages/media/devices.rs create mode 100644 examples/gallery/pages/media/sculpt.rs create mode 100644 src/media/camera.rs create mode 100644 src/media/model.rs create mode 100644 src/media/record.rs create mode 100644 src/media/tests/devices.rs diff --git a/AGENTS.md b/AGENTS.md index a022611..c9a44d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. - gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. - Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures. +- 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`). @@ -143,6 +144,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui styles a drag-over only on an element with its own hitbox. A veil that shows while files hover takes the drop itself (`chat::DragDropOverlay`). - gpui 0.2.2 never clears `debug_bounds`: a test sees an element appear, not leave. - The system time zone comes from `typography::format::system_zone`, which fails loud; jiff's `TimeZone::system()` falls back to UTC in silence. +- A Select with nothing to pick stays shut: disabled, it shows its placeholder; open, it fails (`media::DeviceSelector` says no device was found). - A list opens with its cursor on the current choice: Select on the chosen row, Combobox on its value, MultiSelect on the first one ticked, SelectableList on its first selected row. A selection the owner makes, not the echo of the list's own pick, moves SelectableList's cursor there. - A search step, a tool call or an agent's step says where it stands with `chat::StepState` and draws it with `chat::step_mark`. - A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space. diff --git a/TASKS.md b/TASKS.md index d5aa3dc..01c0a1a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -92,7 +92,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T26a Media · Images - [x] T26b Media · Crop and annotate - [x] T26c Media · Video -- [ ] T26d Media · Audio and controls +- [x] T26d Media · Audio and controls - [ ] T26e Media · Capture and devices - [ ] T27 Files - [ ] T28 Messaging @@ -315,3 +315,4 @@ Per-component lines live in `tasks/`. Tags there: | T26c | 2 | FAIL | the bar's fade restarted an animation id per rest and wake, which rebuilt the scrubber's drag, hover and focus under it | | T26c | 3 | PASS | | | T26d | 1 | FAIL | the playlist's keys started from the first row, not the song under way; the waveform's bar fit sat untested in paint | +| T26d | 2 | PASS | | diff --git a/examples/gallery/pages/media/audio.rs b/examples/gallery/pages/media/audio.rs index 33f1e44..8041add 100644 --- a/examples/gallery/pages/media/audio.rs +++ b/examples/gallery/pages/media/audio.rs @@ -8,7 +8,7 @@ use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window use crate::{ probe::probe, - ui::{keep, noise, section}, + ui::{change, keep, noise, section}, }; const COVER: &str = concat!( @@ -139,14 +139,6 @@ fn state(window: &mut Window, cx: &mut App) -> Entity { ) } -/// Changes the demo's listening and redraws. -fn change(state: &Entity, cx: &mut App, edit: impl FnOnce(&mut Listening)) { - state.update(cx, |now, cx| { - edit(now); - cx.notify(); - }); -} - fn controls(id: &'static str, state: &Entity, now: &Listening) -> MediaControls { let (play, back, forward, shuffle, repeat) = ( state.clone(), diff --git a/examples/gallery/pages/media/devices.rs b/examples/gallery/pages/media/devices.rs new file mode 100644 index 0000000..4ceb001 --- /dev/null +++ b/examples/gallery/pages/media/devices.rs @@ -0,0 +1,244 @@ +use std::{path::Path, sync::Arc}; + +use ely_gpui_component::{ + media::{ + CameraPreview, Device, DeviceKind, DeviceSelector, MicLevelMeter, ModelViewer, Orbit, + Recording, ScreenRecorderControls, + }, + theme::{ActiveTheme, TextSize}, +}; +use gpui::{ + App, ImageSource, IntoElement, ParentElement, RenderImage, SharedString, Styled, Window, div, + px, +}; + +use super::sculpt; +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +const OLIVE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/atrium-olive.jpg" +); +/// The model's frame, in pixels: twice the viewer's points. +const FRAME: (u32, u32) = (960, 640); + +/// The demo recording and what it takes in. +#[derive(Clone)] +struct Recorder { + state: Recording, + mic: bool, + camera: bool, +} + +pub fn recorder(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep( + "media-recorder", + || Recorder { + state: Recording::Idle, + mic: true, + camera: false, + }, + window, + cx, + ); + let now = state.read(cx).clone(); + let (record, pause, resume, stop, mic, camera) = ( + state.clone(), + state.clone(), + state.clone(), + state.clone(), + state.clone(), + state.clone(), + ); + let controls = ScreenRecorderControls::new("media-recorder", now.state) + .on_record(move |_, cx| { + let since = cx.background_executor().now(); + change(&record, cx, |it| it.state = Recording::Running { since }) + }) + .on_pause(move |_, cx| { + let at = cx.background_executor().now(); + change(&pause, cx, |it| { + if let Recording::Running { since } = it.state { + it.state = Recording::Paused { since, at }; + } + }) + }) + .on_resume(move |_, cx| { + let now = cx.background_executor().now(); + change(&resume, cx, |it| { + if let Recording::Paused { since, at } = it.state { + it.state = Recording::Running { + since: since + (now - at), + }; + } + }) + }) + .on_stop(move |_, cx| change(&stop, cx, |it| it.state = Recording::Idle)) + .mic(now.mic, move |on, _, cx| change(&mic, cx, |it| it.mic = on)) + .camera(now.camera, move |on, _, cx| { + change(&camera, cx, |it| it.camera = on) + }); + section( + "ScreenRecorderControls", + "Record, then pause, resume or stop. While it runs, a live dot and the time it has run; the mic and camera toggles choose what it takes.", + cx, + ) + .child(probe("media-recorder", div().flex().child(controls))) +} + +pub fn camera(cx: &mut App) -> impl IntoElement + use<> { + let preview = |key: &'static str| div().w(px(300.)).child(CameraPreview::new(key, 1.5)); + section( + "CameraPreview", + "What a camera sees, in its own shape, from frames the host hands in. With no frame, the camera is off.", + cx, + ) + .child(probe( + "media-camera", + div() + .flex() + .flex_wrap() + .gap_4() + .child( + div().w(px(300.)).child( + CameraPreview::new("media-camera-on", 1.5) + .frame(Path::new(OLIVE)) + .name("Studio camera"), + ), + ) + .child(preview("media-camera-off")), + )) +} + +pub fn mic(cx: &mut App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let (small, muted) = (theme.text_size(TextSize::Sm), theme.colors.fg_muted); + let row = |label: &'static str, level: f32| { + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .w(px(80.)) + .text_size(small) + .text_color(muted) + .child(label), + ) + .child(div().flex_1().child(MicLevelMeter::new(level))) + }; + section( + "MicLevelMeter", + "A microphone's level as it comes in: amber near the top, red at the top.", + cx, + ) + .child(probe( + "media-mic", + div() + .w(px(360.)) + .flex() + .flex_col() + .gap_3() + .child(row("Quiet", 0.3)) + .child(row("Speaking", 0.84)) + .child(row("Too loud", 0.98)), + )) +} + +fn device(key: &str, name: &str) -> Device { + Device { + key: SharedString::from(key.to_string()), + name: SharedString::from(name.to_string()), + } +} + +pub fn selectors(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let chosen = keep( + "media-devices", + || { + ( + SharedString::from("mic-desk"), + SharedString::from("cam-studio"), + ) + }, + window, + cx, + ); + let (mic_now, cam_now) = chosen.read(cx).clone(); + let (mic, cam) = (chosen.clone(), chosen); + let mics = [ + device("mic-desk", "Desk microphone"), + device("mic-headset", "Headset"), + ]; + section( + "DeviceSelector", + "A microphone, a camera or a speaker to use, each with its kind's icon. With none attached, it says so and stays shut.", + cx, + ) + .child(probe( + "media-devices", + div() + .w(px(280.)) + .flex() + .flex_col() + .gap_3() + .child( + DeviceSelector::new("media-mics", DeviceKind::Microphone, mics) + .chosen(mic_now) + .on_change(move |key, _, cx| change(&mic, cx, |it| it.0 = key.clone())), + ) + .child( + DeviceSelector::new("media-cams", DeviceKind::Camera, [device("cam-studio", "Studio camera")]) + .chosen(cam_now) + .on_change(move |key, _, cx| change(&cam, cx, |it| it.1 = key.clone())), + ) + .child(DeviceSelector::new("media-speakers", DeviceKind::Speaker, [])), + )) +} + +/// The demo model: its orbit and the frame drawn for it. +struct Model { + orbit: Orbit, + frame: Arc, +} + +fn drawn(orbit: Orbit) -> Arc { + Arc::new(RenderImage::new(vec![sculpt::draw( + orbit, FRAME.0, FRAME.1, + )])) +} + +pub fn model(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep( + "media-model", + || Model { + orbit: Orbit::START, + frame: drawn(Orbit::START), + }, + window, + cx, + ); + let (orbit, frame) = { + let now = state.read(cx); + (now.orbit, now.frame.clone()) + }; + let viewer = ModelViewer::new("media-model", 1.5, orbit) + .frame(ImageSource::Render(frame)) + .on_orbit(move |orbit, window, cx| { + let old = state.update(cx, |model, cx| { + model.orbit = orbit; + cx.notify(); + std::mem::replace(&mut model.frame, drawn(orbit)) + }); + cx.drop_image(old, Some(window)); + }); + section( + "3DModelViewer", + "A model the host renders, a frame for each view. A drag or the arrows turn around it; Command-scroll, + and - bring it nearer; 0 or the reset button returns.", + cx, + ) + .child(probe("media-model", div().w(px(480.)).child(viewer))) +} diff --git a/examples/gallery/pages/media/mod.rs b/examples/gallery/pages/media/mod.rs index 25b94ec..930ada1 100644 --- a/examples/gallery/pages/media/mod.rs +++ b/examples/gallery/pages/media/mod.rs @@ -1,6 +1,8 @@ mod audio; +mod devices; mod edit; mod images; +mod sculpt; mod video; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -89,6 +91,21 @@ const SCRIPT: &[Step] = &[ Step::DownAt("media-transport", 180.0, 20.0), Step::UpAt("media-transport", 180.0, 20.0), Step::Wait(300), + Step::DownAt("media-recorder", 45.0, 18.0), + Step::UpAt("media-recorder", 45.0, 18.0), + Step::Wait(1500), + Step::Shot("recording"), + Step::DownAt("media-devices", 140.0, 18.0), + Step::UpAt("media-devices", 140.0, 18.0), + Step::Wait(300), + Step::Shot("devices"), + Step::Key("escape"), + Step::DownAt("media-model", 240.0, 160.0), + Step::DragTo("media-model", 360.0, 200.0), + Step::UpAt("media-model", 360.0, 200.0), + Step::Key("="), + Step::Wait(300), + Step::Shot("orbited"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { @@ -109,5 +126,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(audio::speed(window, cx)) .child(audio::transport(window, cx)) .child(audio::playlist(window, cx)) + .child(devices::recorder(window, cx)) + .child(devices::camera(cx)) + .child(devices::mic(cx)) + .child(devices::selectors(window, cx)) + .child(devices::model(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/media/sculpt.rs b/examples/gallery/pages/media/sculpt.rs new file mode 100644 index 0000000..7ac2eb8 --- /dev/null +++ b/examples/gallery/pages/media/sculpt.rs @@ -0,0 +1,135 @@ +use std::f32::consts::TAU; + +use ely_gpui_component::media::Orbit; +use image::{Frame, RgbaImage}; + +type Vec3 = [f32; 3]; + +/// The ring's radius, its tube's, and the steps around each. +const RING: f32 = 1.0; +const TUBE: f32 = 0.38; +const AROUND: usize = 56; +const ACROSS: usize = 24; +/// Half the camera's field of view, and how far it sits when the torus just fits. +const HALF_VIEW: f32 = 0.35; +const FIT: f32 = 1.55 / 0.35; +/// A matte stone, lit from the upper left. +const STONE: Vec3 = [196.0, 186.0, 172.0]; + +fn sub(a: Vec3, b: Vec3) -> Vec3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn dot(a: Vec3, b: Vec3) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: Vec3, b: Vec3) -> Vec3 { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn unit(a: Vec3) -> Vec3 { + let length = dot(a, a).sqrt(); + [a[0] / length, a[1] / length, a[2] / length] +} + +/// A point on the torus, `u` around the ring and `v` around the tube. +fn point(u: f32, v: f32) -> Vec3 { + let reach = RING + TUBE * v.cos(); + [reach * u.cos(), TUBE * v.sin(), reach * u.sin()] +} + +/// The torus as seen from `orbit`, `width` by `height` pixels in BGRA on a clear ground. +pub fn draw(orbit: Orbit, width: u32, height: u32) -> Frame { + let away = FIT * orbit.distance; + let eye = [ + away * orbit.pitch.cos() * orbit.yaw.sin(), + away * orbit.pitch.sin(), + away * orbit.pitch.cos() * orbit.yaw.cos(), + ]; + let ahead = unit(sub([0.0; 3], eye)); + let right = unit(cross(ahead, [0.0, 1.0, 0.0])); + let up = cross(right, ahead); + let light = unit([ + -right[0] + 1.6 * up[0] - ahead[0], + -right[1] + 1.6 * up[1] - ahead[1], + -right[2] + 1.6 * up[2] - ahead[2], + ]); + let focal = height as f32 / 2.0 / HALF_VIEW.tan(); + let project = |p: Vec3| { + let seen = sub(p, eye); + let depth = dot(seen, ahead); + ( + width as f32 / 2.0 + dot(seen, right) / depth * focal, + height as f32 / 2.0 - dot(seen, up) / depth * focal, + depth, + ) + }; + let step = |ix: usize, count: usize| ix as f32 / count as f32 * TAU; + let mut faces = Vec::with_capacity(AROUND * ACROSS); + for i in 0..AROUND { + for j in 0..ACROSS { + let corners = [ + point(step(i, AROUND), step(j, ACROSS)), + point(step(i + 1, AROUND), step(j, ACROSS)), + point(step(i + 1, AROUND), step(j + 1, ACROSS)), + point(step(i, AROUND), step(j + 1, ACROSS)), + ]; + let normal = unit(cross( + sub(corners[3], corners[0]), + sub(corners[1], corners[0]), + )); + if dot(normal, sub(eye, corners[0])) <= 0.0 { + continue; + } + let lit = 0.42 + 0.58 * dot(normal, light).max(0.0); + let shade = STONE.map(|channel| (channel * lit).min(255.0) as u8); + let placed = corners.map(project); + let depth = placed.iter().map(|corner| corner.2).sum::(); + faces.push((depth, placed.map(|(x, y, _)| (x, y)), shade)); + } + } + faces.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut pixels = RgbaImage::new(width, height); + for (_, [a, b, c, d], [red, green, blue]) in faces { + for triangle in [[a, b, c], [a, c, d]] { + fill(&mut pixels, triangle, [blue, green, red, 255]); + } + } + Frame::new(pixels) +} + +/// Fills a triangle given either way round. +fn fill(pixels: &mut RgbaImage, [a, b, c]: [(f32, f32); 3], color: [u8; 4]) { + let edge = |p: (f32, f32), q: (f32, f32), x: f32, y: f32| { + (q.0 - p.0) * (y - p.1) - (q.1 - p.1) * (x - p.0) + }; + let area = edge(a, b, c.0, c.1); + if area.abs() < f32::EPSILON { + return; + } + let (width, height) = (pixels.width() as f32, pixels.height() as f32); + let low_x = a.0.min(b.0).min(c.0).max(0.0) as u32; + let high_x = a.0.max(b.0).max(c.0).min(width - 1.0).max(0.0) as u32; + let low_y = a.1.min(b.1).min(c.1).max(0.0) as u32; + let high_y = a.1.max(b.1).max(c.1).min(height - 1.0).max(0.0) as u32; + for y in low_y..=high_y { + for x in low_x..=high_x { + let (x_at, y_at) = (x as f32 + 0.5, y as f32 + 0.5); + let inside = [ + edge(a, b, x_at, y_at), + edge(b, c, x_at, y_at), + edge(c, a, x_at, y_at), + ] + .iter() + .all(|side| side * area >= 0.0); + if inside { + pixels.put_pixel(x, y, image::Rgba(color)); + } + } + } +} diff --git a/examples/gallery/ui.rs b/examples/gallery/ui.rs index ede1c2b..91a837c 100644 --- a/examples/gallery/ui.rs +++ b/examples/gallery/ui.rs @@ -87,6 +87,14 @@ pub fn keep( window.use_keyed_state(key, cx, move |_, _| init()) } +/// Edits a demo's state in place and redraws. +pub fn change(state: &Entity, cx: &mut App, edit: impl FnOnce(&mut T)) { + state.update(cx, |state, cx| { + edit(state); + cx.notify(); + }); +} + /// Replaces a demo's state and redraws. pub fn set(state: &Entity, value: T, cx: &mut App) { state.update(cx, |state, cx| { diff --git a/src/data_display/measures.rs b/src/data_display/measures.rs index 10b6247..2ee9283 100644 --- a/src/data_display/measures.rs +++ b/src/data_display/measures.rs @@ -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() { diff --git a/src/data_display/mod.rs b/src/data_display/mod.rs index 9a875b3..4268c87 100644 --- a/src/data_display/mod.rs +++ b/src/data_display/mod.rs @@ -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; diff --git a/src/forms/select.rs b/src/forms/select.rs index 5de0360..a99d2f8 100644 --- a/src/forms/select.rs +++ b/src/forms/select.rs @@ -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 ); diff --git a/src/forms/tests/choices.rs b/src/forms/tests/choices.rs index 8a2a0db..4b8e72b 100644 --- a/src/forms/tests/choices.rs +++ b/src/forms/tests/choices.rs @@ -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) -> 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)); +} diff --git a/src/generative/mod.rs b/src/generative/mod.rs index 63b5f6e..642b420 100644 --- a/src/generative/mod.rs +++ b/src/generative/mod.rs @@ -5,7 +5,7 @@ mod downloads; mod embed; mod grid; mod hardware; -mod lens; +pub(crate) mod lens; mod mask; mod models; mod playground; diff --git a/src/media/camera.rs b/src/media/camera.rs new file mode 100644 index 0000000..53e8004 --- /dev/null +++ b/src/media/camera.rs @@ -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, + name: Option, +} + +impl CameraPreview { + /// `ratio` is the camera's width over its height. + pub fn new(id: impl Into, 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) -> 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) -> 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, + chosen: Option, + on_change: Option, +} + +impl DeviceSelector { + pub fn new( + id: impl Into, + kind: DeviceKind, + devices: impl IntoIterator, + ) -> Self { + let devices: Vec = 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) -> 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, + } + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs index 92fe959..2f76e8e 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -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}; diff --git a/src/media/model.rs b/src/media/model.rs new file mode 100644 index 0000000..488fd95 --- /dev/null +++ b/src/media/model.rs @@ -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; + +/// 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, + orbit: Orbit, + on_orbit: Option, +} + +impl ModelViewer { + /// `ratio` is the view's width over its height. + pub fn new(id: impl Into, 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) -> 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::::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, 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); + } +} diff --git a/src/media/record.rs b/src/media/record.rs new file mode 100644 index 0000000..11bcd3d --- /dev/null +++ b/src/media/record.rs @@ -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, + on_pause: Option, + on_resume: Option, + on_stop: Option, +} + +impl ScreenRecorderControls { + pub fn new(id: impl Into, 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.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| { + 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); + } +} diff --git a/src/media/tests/audio.rs b/src/media/tests/audio.rs index 1be9dce..c62ac16 100644 --- a/src/media/tests/audio.rs +++ b/src/media/tests/audio.rs @@ -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, diff --git a/src/media/tests/devices.rs b/src/media/tests/devices.rs new file mode 100644 index 0000000..f587cb7 --- /dev/null +++ b/src/media/tests/devices.rs @@ -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) -> impl IntoElement { + let hear = |what: &'static str, cx: &mut Context| { + 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, 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, +} + +impl Render for Watching { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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, + heard: Vec, +} + +impl Render for Choosing { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, + framed: bool, +} + +impl Render for Orbiting { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &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, 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()); +} diff --git a/src/media/tests/mod.rs b/src/media/tests/mod.rs index 3ead71d..2453dec 100644 --- a/src/media/tests/mod.rs +++ b/src/media/tests/mod.rs @@ -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); +} diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 687b7de..2e551f3 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -281,11 +281,11 @@ ## T26e Media · Capture and devices -- [ ] ScreenRecorderControls -- [ ] CameraPreview — host frames -- [ ] MicLevelMeter -- [ ] DeviceSelector -- [ ] 3DModelViewer — host frames, orbit input +- [x] ScreenRecorderControls +- [x] CameraPreview — host frames +- [x] MicLevelMeter +- [x] DeviceSelector +- [x] 3DModelViewer — host frames, orbit input ## T27 Files — `src/files`