ScreenRecorderControls holds a recording in one pill: Record, then a live dot and the time it has run beside pause or resume and stop, with mic and camera toggles. CameraPreview shows host frames in the camera's shape, or the camera off. MicLevelMeter lights segments up to the level, amber and red near the top, with tones shared from data_display. DeviceSelector picks a microphone, camera or speaker through Select, which may now be empty while shut. ModelViewer shows the host's frame for an orbit that a drag, the arrows, Command-scroll, + and - and a reset move; the orbit is seeded from the host so fast drags add up. The gallery draws a stone torus per orbit and frees each replaced frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
8.9 KiB
Rust
277 lines
8.9 KiB
Rust
use std::{rc::Rc, time::Instant};
|
|
|
|
use gpui::{
|
|
AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div,
|
|
prelude::*,
|
|
};
|
|
|
|
use crate::{
|
|
buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem},
|
|
data_display::tone,
|
|
feedback::Timer,
|
|
forms::{OnFlag, Run},
|
|
motion::Pulse,
|
|
primitives::IconName,
|
|
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
|
};
|
|
|
|
/// Where a mic meter turns amber, then red.
|
|
const LOUD: (f32, f32) = (0.8, 0.95);
|
|
/// Segments along a mic meter.
|
|
const SEGMENTS: usize = 24;
|
|
|
|
/// How many of a mic meter's segments a level lights.
|
|
pub(crate) fn lit(level: f32) -> usize {
|
|
(level * SEGMENTS as f32).round() as usize
|
|
}
|
|
|
|
/// Where a screen recording stands.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Recording {
|
|
Idle,
|
|
/// Running, counted from `since`; a resume moves `since` on by the pause.
|
|
Running {
|
|
since: Instant,
|
|
},
|
|
/// Paused at `at`, counted from `since`.
|
|
Paused {
|
|
since: Instant,
|
|
at: Instant,
|
|
},
|
|
}
|
|
|
|
/// A screen recording's controls in one pill. Idle, Record starts it; running, a live dot and the time it has run sit before pause and stop; paused, the time holds and resume takes pause's place. Mic and camera toggles follow, each shown with its handler.
|
|
#[derive(IntoElement)]
|
|
pub struct ScreenRecorderControls {
|
|
id: ElementId,
|
|
state: Recording,
|
|
mic: Option<(bool, OnFlag)>,
|
|
camera: Option<(bool, OnFlag)>,
|
|
on_record: Option<Run>,
|
|
on_pause: Option<Run>,
|
|
on_resume: Option<Run>,
|
|
on_stop: Option<Run>,
|
|
}
|
|
|
|
impl ScreenRecorderControls {
|
|
pub fn new(id: impl Into<ElementId>, state: Recording) -> Self {
|
|
if let Recording::Paused { since, at } = state {
|
|
assert!(since <= at, "a pause before its recording started");
|
|
}
|
|
Self {
|
|
id: id.into(),
|
|
state,
|
|
mic: None,
|
|
camera: None,
|
|
on_record: None,
|
|
on_pause: None,
|
|
on_resume: None,
|
|
on_stop: None,
|
|
}
|
|
}
|
|
|
|
pub fn on_record(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
|
self.on_record = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
pub fn on_pause(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
|
self.on_pause = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
pub fn on_resume(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
|
self.on_resume = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
pub fn on_stop(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
|
self.on_stop = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
/// Whether the microphone records too, and what gets the change.
|
|
pub fn mic(
|
|
mut self,
|
|
on: bool,
|
|
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
self.mic = Some((on, Rc::new(handler)));
|
|
self
|
|
}
|
|
|
|
/// Whether the camera records too, and what gets the change.
|
|
pub fn camera(
|
|
mut self,
|
|
on: bool,
|
|
handler: impl Fn(bool, &mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
self.camera = Some((on, Rc::new(handler)));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for ScreenRecorderControls {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let id = self.id.clone();
|
|
let action = |key: &'static str, icon: IconName, tip: &'static str, run: Option<Run>| {
|
|
run.map(|run| {
|
|
IconButton::new((id.clone(), key), icon)
|
|
.variant(ButtonVariant::Ghost)
|
|
.size(ControlSize::Sm)
|
|
.tooltip(tip)
|
|
.on_click(move |_, window, cx| {
|
|
log::info!("screen recorder: {key}");
|
|
run(window, cx)
|
|
})
|
|
})
|
|
};
|
|
let dot = |live: bool| -> AnyElement {
|
|
let color = if live { colors.danger } else { colors.fg_muted };
|
|
let dot = div().size(theme.status_dot()).rounded_full().bg(color);
|
|
match live {
|
|
true => Pulse::new((id.clone(), "live"))
|
|
.child(dot)
|
|
.into_any_element(),
|
|
false => dot.into_any_element(),
|
|
}
|
|
};
|
|
let time = |since: Instant, at: Option<Instant>| {
|
|
Timer::new((id.clone(), "time"), since)
|
|
.stopped(at)
|
|
.size(TextSize::Sm)
|
|
};
|
|
let body = match self.state {
|
|
Recording::Idle => div().children(self.on_record.map(|run| {
|
|
Button::new((id.clone(), "record"), "Record")
|
|
.icon(IconName::CircleDot)
|
|
.variant(ButtonVariant::Ghost)
|
|
.size(ControlSize::Sm)
|
|
.on_click(move |_, window, cx| {
|
|
log::info!("screen recorder: record");
|
|
run(window, cx)
|
|
})
|
|
})),
|
|
Recording::Running { since } => div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.pl_2()
|
|
.child(dot(true))
|
|
.child(time(since, None))
|
|
.children(action("pause", IconName::Pause, "Pause", self.on_pause))
|
|
.children(action("stop", IconName::Square, "Stop", self.on_stop)),
|
|
Recording::Paused { since, at } => div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.pl_2()
|
|
.child(dot(false))
|
|
.child(time(since, Some(at)))
|
|
.children(action("resume", IconName::Play, "Resume", self.on_resume))
|
|
.children(action("stop", IconName::Square, "Stop", self.on_stop)),
|
|
};
|
|
let toggle = |key: &'static str,
|
|
icons: (IconName, IconName),
|
|
tip: &'static str,
|
|
set: Option<(bool, OnFlag)>| {
|
|
set.map(|(on, set)| {
|
|
let item = ToggleItem::new(key)
|
|
.icon(if on { icons.0 } else { icons.1 })
|
|
.tooltip(tip);
|
|
ToggleButton::new((id.clone(), key), item, on)
|
|
.size(ControlSize::Sm)
|
|
.on_toggle(move |to, window, cx| {
|
|
log::info!("screen recorder: {key} {}", if to { "on" } else { "off" });
|
|
set(to, window, cx)
|
|
})
|
|
})
|
|
};
|
|
let devices = self.mic.is_some() || self.camera.is_some();
|
|
div()
|
|
.flex_none()
|
|
.flex()
|
|
.items_center()
|
|
.gap_1()
|
|
.p_1()
|
|
.rounded_full()
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.bg(colors.surface)
|
|
.child(body)
|
|
.when(devices, |pill| {
|
|
pill.child(
|
|
div()
|
|
.w_px()
|
|
.h(theme.icon_size(IconSize::Md))
|
|
.bg(colors.border),
|
|
)
|
|
})
|
|
.children(toggle(
|
|
"mic",
|
|
(IconName::Mic, IconName::MicOff),
|
|
"Microphone",
|
|
self.mic,
|
|
))
|
|
.children(toggle(
|
|
"camera",
|
|
(IconName::Video, IconName::VideoOff),
|
|
"Camera",
|
|
self.camera,
|
|
))
|
|
}
|
|
}
|
|
|
|
/// A microphone's level as it comes in, 0 to 1: segments lit up to it, amber near the top and red at the top. The host sends levels as they come.
|
|
#[derive(IntoElement)]
|
|
pub struct MicLevelMeter {
|
|
level: f32,
|
|
}
|
|
|
|
impl MicLevelMeter {
|
|
pub fn new(level: f32) -> Self {
|
|
assert!((0.0..=1.0).contains(&level), "a level of {level}");
|
|
Self { level }
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for MicLevelMeter {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let lit = lit(self.level);
|
|
let (unlit, round) = (colors.border, theme.radius(Radius::Sm));
|
|
div()
|
|
.debug_selector(|| "mic-level".into())
|
|
.w_full()
|
|
.h(theme.meter_track())
|
|
.flex()
|
|
.gap_0p5()
|
|
.children((0..SEGMENTS).map(|ix| {
|
|
let share = (ix + 1) as f32 / SEGMENTS as f32;
|
|
let color = if ix < lit {
|
|
tone(share, LOUD, colors)
|
|
} else {
|
|
unlit
|
|
};
|
|
div().flex_1().h_full().rounded(round).bg(color)
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{SEGMENTS, lit};
|
|
|
|
#[test]
|
|
fn a_level_lights_the_segments_up_to_it() {
|
|
assert_eq!(lit(0.0), 0);
|
|
assert_eq!(lit(0.02), 0);
|
|
assert_eq!(lit(0.03), 1);
|
|
assert_eq!(lit(0.5), SEGMENTS / 2);
|
|
assert_eq!(lit(1.0), SEGMENTS);
|
|
}
|
|
}
|