simple_editor\ui/
capture_ui.rs

1//! Screen recording + voiceover windows (both non-modal, both keep the editor usable).
2//!
3//! Screen recorder: output folder, fps, bitrate (or CRF), area (Whole desktop / a Region typed in
4//! desktop pixels), microphone combo (`engine::capture::audio_devices`), desktop audio toggle, cursor
5//! toggle, and "Record when the editor loses focus" (auto start/stop) — the app watches focus and drives
6//! `engine::capture`. While recording: elapsed time, a stop button and a note that the file is imported
7//! into the library when it finishes. Both windows only *ask*: the app owns the running recording (and
8//! its output path) and imports the file itself when it stops.
9//!
10//! Voiceover: input device, channels, a level meter, "Record from the playhead" (playback rolls while
11//! recording, so the take lines up), stop → the WAV is imported and placed on an audio track at the start
12//! time, with a "Retake" that deletes the last take and records again.
13
14use crate::engine::capture::{audio_devices, ScreenCaptureOptions, VoiceoverOptions};
15use crate::settings::Settings;
16use crate::theme::Palette;
17use crate::ui::combo;
18use crate::ui::tools::{glyph_text_button, Glyph};
19use eframe::egui;
20use std::path::PathBuf;
21
22#[derive(Default)]
23pub struct CaptureUi {
24    pub screen_open: bool,
25    pub voice_open: bool,
26    pub elapsed: f64,
27    /// Region in desktop pixels (x, y, w, h) — typed in the window, used in "region" mode.
28    pub region: Option<(i32, i32, u32, u32)>,
29    /// "desktop" | "region"
30    pub area: String,
31    /// Input level 0..1 for the voiceover meter (fed by the app while recording).
32    pub level: f32,
33    /// Roll playback while recording the voiceover, so the take lines up with the timeline.
34    pub from_playhead: bool,
35    /// Timeline time the current voiceover take started at.
36    pub voice_start: Option<f64>,
37}
38
39/// What the app should do this frame.
40#[derive(Default)]
41pub struct CaptureResponse {
42    pub start_screen: bool,
43    pub stop_screen: bool,
44    pub start_voice: bool,
45    pub stop_voice: bool,
46    /// Filled together with `start_screen` / `start_voice` — the options the window built.
47    pub screen: Option<ScreenCaptureOptions>,
48    pub voice: Option<VoiceoverOptions>,
49    /// Current "Record when the editor loses focus" state (the app watches focus and drives capture).
50    pub auto_on_blur: bool,
51    /// Throw the last voiceover take away and record again.
52    pub retake: bool,
53}
54
55const AREAS: [(&str, &str); 2] = [("desktop", "Whole desktop"), ("region", "Region")];
56
57/// Where recordings go: the configured folder, else the user's Videos folder, else the temp dir.
58pub fn capture_dir(settings: &Settings) -> PathBuf {
59    if !settings.capture_dir.trim().is_empty() {
60        return PathBuf::from(settings.capture_dir.trim());
61    }
62    let videos = std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join("Videos"));
63    match videos {
64        Some(v) if v.is_dir() => v,
65        _ => std::env::temp_dir(),
66    }
67}
68
69fn out_path(settings: &Settings, prefix: &str, ext: &str) -> PathBuf {
70    capture_dir(settings).join(format!("{prefix}-{}.{ext}", Settings::now()))
71}
72
73pub fn show(
74    ctx: &egui::Context,
75    state: &mut CaptureUi,
76    settings: &mut Settings,
77    recording_screen: bool,
78    recording_voice: bool,
79    palette: &Palette,
80) -> CaptureResponse {
81    // the app owns the output path of a running recording and imports it itself when it stops
82    let mut r = CaptureResponse { auto_on_blur: settings.capture_on_blur, ..Default::default() };
83    if state.area.is_empty() {
84        state.area = "desktop".into();
85    }
86    if state.screen_open {
87        screen_window(ctx, state, settings, recording_screen, &mut r);
88    }
89    if state.voice_open {
90        voice_window(ctx, state, settings, recording_voice, palette, &mut r);
91    }
92    r
93}
94
95fn screen_window(
96    ctx: &egui::Context,
97    state: &mut CaptureUi,
98    settings: &mut Settings,
99    recording: bool,
100    r: &mut CaptureResponse,
101) {
102    let devices = audio_devices();
103    let has_loopback = devices.iter().any(|(_, lb)| *lb);
104    let mut open = state.screen_open;
105    egui::Window::new("Screen Recording").open(&mut open).resizable(false).default_width(320.0).show(ctx, |ui| {
106        ui.add_enabled_ui(!recording, |ui| {
107            egui::Grid::new("cap_opts").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
108                ui.label("Folder");
109                ui.horizontal(|ui| {
110                    let mut dir = capture_dir(settings).to_string_lossy().into_owned();
111                    if ui.add(egui::TextEdit::singleline(&mut dir).desired_width(160.0)).changed() {
112                        settings.capture_dir = dir.clone();
113                    }
114                    if ui.small_button("…").clicked() {
115                        if let Some(p) = rfd::FileDialog::new().set_directory(&dir).pick_folder() {
116                            settings.capture_dir = p.to_string_lossy().into_owned();
117                        }
118                    }
119                });
120                ui.end_row();
121
122                ui.label("Frame rate");
123                ui.add(egui::DragValue::new(&mut settings.capture_fps).range(1..=120).suffix(" fps"));
124                ui.end_row();
125
126                ui.label("Bitrate");
127                ui.horizontal(|ui| {
128                    ui.add(
129                        egui::DragValue::new(&mut settings.capture_bitrate_kbps).range(0..=200_000).suffix(" kbit/s"),
130                    );
131                    if settings.capture_bitrate_kbps == 0 {
132                        ui.weak(format!("quality CRF {}", settings.crf));
133                    }
134                });
135                ui.end_row();
136
137                ui.label("Capture");
138                combo(ui, "cap_area", &mut state.area, &AREAS, Some(110.0));
139                ui.end_row();
140
141                if state.area == "region" {
142                    // ponytail: typed coordinates, not a drag-to-pick desktop overlay — a fullscreen
143                    // transparent viewport is a lot of machinery for four numbers.
144                    let (mut x, mut y, mut w, mut h) = state.region.unwrap_or((0, 0, 1280, 720));
145                    ui.label("Region");
146                    ui.horizontal(|ui| {
147                        ui.add(egui::DragValue::new(&mut x).prefix("x ").speed(2.0));
148                        ui.add(egui::DragValue::new(&mut y).prefix("y ").speed(2.0));
149                        ui.add(egui::DragValue::new(&mut w).prefix("w ").range(16..=16384).speed(2.0));
150                        ui.add(egui::DragValue::new(&mut h).prefix("h ").range(16..=16384).speed(2.0));
151                    });
152                    state.region = Some((x, y, w, h));
153                    ui.end_row();
154                }
155
156                ui.label("Microphone");
157                let mut opts: Vec<(&str, &str)> = vec![("", "None")];
158                opts.extend(devices.iter().map(|(n, _)| (n.as_str(), n.as_str())));
159                combo(ui, "cap_mic", &mut settings.capture_mic, &opts, Some(180.0));
160                ui.end_row();
161            });
162            ui.add_enabled_ui(has_loopback, |ui| {
163                ui.checkbox(&mut settings.capture_desktop_audio, "Desktop audio").on_disabled_hover_text(
164                    "No loopback input found — enable \"Stereo Mix\" in Windows sound settings.",
165                );
166            });
167            ui.checkbox(&mut settings.capture_cursor, "Record the mouse cursor");
168            if ui.checkbox(&mut settings.capture_on_blur, "Record when the editor loses focus").changed() {
169                settings.save();
170            }
171            r.auto_on_blur = settings.capture_on_blur;
172        });
173        ui.separator();
174        ui.horizontal(|ui| {
175            if recording {
176                crate::ui::tools::glyph_label(ui, Glyph::Record, ui.visuals().error_fg_color);
177                ui.colored_label(ui.visuals().error_fg_color, "REC");
178                ui.label(clock(state.elapsed));
179                if ui.button("Stop").clicked() {
180                    r.stop_screen = true;
181                }
182            } else if glyph_text_button(ui, Glyph::Record, "Record").clicked() {
183                r.screen = Some(screen_options(settings, state, out_path(settings, "screen", "mp4"), has_loopback));
184                r.start_screen = true;
185                settings.save();
186            }
187        });
188        ui.weak("The file is added to the library when the recording stops.");
189    });
190    state.screen_open = open;
191}
192
193/// The options the recorder should run with (the region only applies in "region" mode).
194pub(crate) fn screen_options(
195    settings: &Settings,
196    state: &CaptureUi,
197    out: PathBuf,
198    has_loopback: bool,
199) -> ScreenCaptureOptions {
200    ScreenCaptureOptions {
201        out,
202        fps: settings.capture_fps.clamp(1, 120),
203        bitrate_kbps: settings.capture_bitrate_kbps,
204        crf: settings.crf,
205        region: if state.area == "region" { state.region } else { None },
206        mic: settings.capture_mic.clone(),
207        desktop_audio: settings.capture_desktop_audio && has_loopback,
208        cursor: settings.capture_cursor,
209    }
210}
211
212/// The options a voiceover take should run with.
213pub(crate) fn voice_options(settings: &Settings) -> VoiceoverOptions {
214    VoiceoverOptions {
215        out: out_path(settings, "voice", "wav"),
216        device: settings.voice_device.clone(),
217        sample_rate: 48_000,
218        channels: settings.voice_channels.clamp(1, 2),
219    }
220}
221
222fn voice_window(
223    ctx: &egui::Context,
224    state: &mut CaptureUi,
225    settings: &mut Settings,
226    recording: bool,
227    palette: &Palette,
228    r: &mut CaptureResponse,
229) {
230    let devices = audio_devices();
231    let mut open = state.voice_open;
232    egui::Window::new("Voiceover").open(&mut open).resizable(false).default_width(280.0).show(ctx, |ui| {
233        ui.add_enabled_ui(!recording, |ui| {
234            egui::Grid::new("vo_opts").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
235                ui.label("Input");
236                let opts: Vec<(&str, &str)> = devices.iter().map(|(n, _)| (n.as_str(), n.as_str())).collect();
237                combo(ui, "vo_dev", &mut settings.voice_device, &opts, Some(170.0));
238                ui.end_row();
239                ui.label("Channels");
240                ui.horizontal(|ui| {
241                    ui.selectable_value(&mut settings.voice_channels, 1, "Mono");
242                    ui.selectable_value(&mut settings.voice_channels, 2, "Stereo");
243                });
244                ui.end_row();
245            });
246            ui.checkbox(&mut state.from_playhead, "Record from the playhead");
247        });
248        meter(ui, state.level, palette);
249        ui.horizontal(|ui| {
250            if recording {
251                crate::ui::tools::glyph_label(ui, Glyph::Record, ui.visuals().error_fg_color);
252                ui.colored_label(ui.visuals().error_fg_color, "REC");
253                ui.label(clock(state.elapsed));
254                if ui.button("Stop").clicked() {
255                    r.stop_voice = true;
256                }
257            } else {
258                let can = !settings.voice_device.is_empty();
259                if ui
260                    .add_enabled_ui(can, |ui| glyph_text_button(ui, Glyph::Mic, "Record"))
261                    .inner
262                    .on_disabled_hover_text("Pick an input.")
263                    .clicked()
264                {
265                    r.voice = Some(voice_options(settings));
266                    r.start_voice = true;
267                    settings.save();
268                }
269                if state.voice_start.is_some() && ui.button("Retake").clicked() {
270                    r.retake = true;
271                    r.start_voice = true;
272                    r.voice = Some(voice_options(settings));
273                }
274            }
275        });
276        if state.from_playhead {
277            ui.weak("Playback rolls while recording; the take lands at the playhead.");
278        }
279    });
280    state.voice_open = open;
281}
282
283/// Flat input-level bar (green → the accent colour as it approaches clipping).
284fn meter(ui: &mut egui::Ui, level: f32, palette: &Palette) {
285    let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width().min(240.0), 8.0), egui::Sense::hover());
286    let p = ui.painter();
287    p.rect_filled(rect, 0, palette.panel);
288    let l = level.clamp(0.0, 1.0);
289    if l > 0.0 {
290        let mut filled = rect;
291        filled.set_width(rect.width() * l);
292        p.rect_filled(filled, 0, if l > 0.95 { palette.playhead } else { palette.waveform });
293    }
294    p.rect_stroke(rect, 0, egui::Stroke::new(1.0, palette.border), egui::StrokeKind::Inside);
295}
296
297/// "M:SS" — recordings are minutes long, not hours.
298fn clock(secs: f64) -> String {
299    let s = secs.max(0.0);
300    format!("{}:{:04.1}", (s / 60.0).floor() as u64, s % 60.0)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn state() -> CaptureUi {
308        CaptureUi { screen_open: true, voice_open: true, area: "desktop".into(), ..Default::default() }
309    }
310
311    #[test]
312    fn options_follow_the_settings() {
313        let mut s = Settings::default();
314        s.capture_fps = 240; // clamped
315        s.capture_mic = "Mic".into();
316        let mut st = state();
317        st.region = Some((10, 20, 640, 480));
318        let o = screen_options(&s, &st, PathBuf::from("a.mp4"), false);
319        assert_eq!(o.fps, 120);
320        assert_eq!(o.region, None, "the region only applies in region mode");
321        assert_eq!(o.mic, "Mic");
322        assert!(!o.desktop_audio, "no loopback device → no desktop audio, whatever the setting says");
323        st.area = "region".into();
324        let o = screen_options(&s, &st, PathBuf::from("a.mp4"), true);
325        assert_eq!(o.region, Some((10, 20, 640, 480)));
326        assert!(o.desktop_audio);
327    }
328
329    /// Headless: both windows lay out, idle and recording, and ask for nothing on their own.
330    #[test]
331    fn show_headless() {
332        let ctx = egui::Context::default();
333        let palette = Palette::new(false, egui::Color32::BLUE);
334        let mut s = Settings::default();
335        for area in ["desktop", "region"] {
336            for rec in [false, true] {
337                let mut st = CaptureUi { area: area.into(), elapsed: 3.5, ..state() };
338                let mut r = CaptureResponse::default();
339                let _ = ctx.run(egui::RawInput::default(), |ctx| r = show(ctx, &mut st, &mut s, rec, rec, &palette));
340                assert!(!r.start_screen && !r.stop_screen && !r.start_voice && !r.stop_voice);
341                assert!(r.screen.is_none() && r.voice.is_none() && !r.retake);
342                assert_eq!(r.auto_on_blur, s.capture_on_blur);
343                assert!(st.screen_open && st.voice_open);
344            }
345        }
346        // region mode seeds an editable region instead of silently recording the whole desktop
347        let mut st = CaptureUi { area: "region".into(), ..state() };
348        let _ = ctx.run(egui::RawInput::default(), |ctx| {
349            show(ctx, &mut st, &mut s, false, false, &palette);
350        });
351        assert!(st.region.is_some(), "region mode always has a concrete region");
352    }
353
354    #[test]
355    fn clock_text() {
356        assert_eq!(clock(0.0), "0:00.0");
357        assert_eq!(clock(65.25), "1:05.2");
358        assert_eq!(clock(-1.0), "0:00.0");
359    }
360
361    #[test]
362    fn capture_dir_falls_back() {
363        let mut s = Settings::default();
364        s.capture_dir = r"C:\shots ".into();
365        assert_eq!(capture_dir(&s), PathBuf::from(r"C:\shots"));
366        s.capture_dir = "  ".into();
367        assert!(capture_dir(&s).is_dir(), "the fallback is always a real folder");
368    }
369}