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`