diff --git a/Cargo.lock b/Cargo.lock index d9af982..64a5744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -216,6 +229,17 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "thiserror", +] + [[package]] name = "dirs" version = "5.0.1" @@ -243,6 +267,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "env_filter" version = "0.1.4" @@ -732,6 +762,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "simd-adler32" version = "0.3.7" @@ -773,6 +809,7 @@ dependencies = [ "core-foundation", "core-foundation-sys", "core-graphics", + "dialoguer", "dirs", "env_logger", "humantime", @@ -868,6 +905,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "utf8parse" version = "0.2.2" @@ -901,7 +944,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -919,13 +971,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -934,42 +1002,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.14" diff --git a/Cargo.toml b/Cargo.toml index a77ce9d..216ee64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ simplerand = "1.6" serde = { version = "1.0", features = ["derive"] } toml = "0.8" dirs = "5.0" +dialoguer = { version = "0.11", default-features = false } [dependencies.clap] version = "4.5" diff --git a/README.md b/README.md index 7b9ca70..8d3a4bd 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,29 @@ t-rec --fps 15 **Note:** Higher framerates produce larger files. The default 4 fps is recommended for most use cases. +### Video Generation + +After recording, t-rec will ask if you also want to generate an MP4 video: + +``` +🎉 🚀 Generating t-rec.gif + +🎬 Also generate MP4 video? (y/n) › +(auto-skip in 15s) +``` + +- Press `y` to generate both GIF and MP4 +- Press `n` or wait 15 seconds to skip + +To always generate video without being asked, use the `--video` flag: + +```sh +t-rec --video # Generate both GIF and MP4 +t-rec --video-only # Generate only MP4, no GIF +``` + +The prompt is skipped in quiet mode (`-q`) or non-interactive environments. + ### Disable idle detection & optimization If you are not happy with the idle detection and optimization, you can disable it with the `-n` or `--natural` parameter. diff --git a/features/interactive-video-prompt.md b/features/interactive-video-prompt.md new file mode 100644 index 0000000..8f66914 --- /dev/null +++ b/features/interactive-video-prompt.md @@ -0,0 +1,305 @@ +# Implementation Plan: Interactive Video Generation Prompt + +## Overview + +Add an interactive prompt at the end of recording that asks users if they want to generate an MP4 video, with a 10-second timeout that defaults to "no". + +**Reference**: [GitHub Issue #219](https://github.com/sassman/t-rec-rs/issues/219) + +## Requirements from Issue #219 + +1. **Trigger condition**: Only prompt when user did NOT use `--video` or `--video-only` +2. **Interactive prompt**: Ask if user wants MP4 video with default [n]o +3. **10-second timeout**: Auto-decline after 10 seconds of no input +4. **Generate video**: If user says yes, generate MP4 as if `--video` was passed + +## User Experience + +### Prompt Display + +``` +🎉 🚀 Generating t-rec.gif + +📋 Recording summary + ├─ fps: 4 + ├─ idle-pause: 3s + ├─ frames: 127 + └─ output: t-rec + +🎬 Also generate MP4 video? [y/N] (auto-skip in 10s): █ +``` + +### Countdown Display + +The countdown should update in-place: +``` +🎬 Also generate MP4 video? [y/N] (auto-skip in 10s): +🎬 Also generate MP4 video? [y/N] (auto-skip in 9s): +... +🎬 Also generate MP4 video? [y/N] (auto-skip in 1s): +``` + +### Responses + +- `y` or `Y` → Generate MP4 +- `n`, `N`, or Enter → Skip MP4 +- Timeout (10s) → Skip MP4 (with message "Skipping video generation") + +--- + +## Implementation Steps + +### Step 1: Add Prompt Module + +**File**: `src/prompt.rs` + +Create a new module for interactive prompts with timeout support: + +```rust +use std::io::{self, Write}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +/// Result of a timed prompt +pub enum PromptResult { + Yes, + No, + Timeout, +} + +/// Prompts the user with a yes/no question with a countdown timeout. +/// +/// Returns `PromptResult::Yes` if user enters 'y' or 'Y', +/// `PromptResult::No` if user enters 'n', 'N', or just presses Enter, +/// `PromptResult::Timeout` if the timeout expires. +pub fn prompt_yes_no_with_timeout(question: &str, timeout_secs: u64) -> PromptResult { + let (tx, rx) = mpsc::channel(); + + // Spawn thread to read user input + let tx_clone = tx.clone(); + thread::spawn(move || { + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_ok() { + let _ = tx_clone.send(Some(input.trim().to_lowercase())); + } + }); + + // Countdown loop + for remaining in (1..=timeout_secs).rev() { + // Print prompt with countdown (overwrite previous line) + print!("\r{} [y/N] (auto-skip in {}s): ", question, remaining); + io::stdout().flush().unwrap(); + + // Check for input with 1-second timeout + match rx.recv_timeout(Duration::from_secs(1)) { + Ok(Some(input)) => { + println!(); // Move to next line + return match input.as_str() { + "y" | "yes" => PromptResult::Yes, + _ => PromptResult::No, + }; + } + Ok(None) => { + println!(); + return PromptResult::No; + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // Continue countdown + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + println!(); + return PromptResult::No; + } + } + } + + // Timeout reached + println!("\r{} [y/N] (auto-skip in 0s): ", question); + println!("Skipping video generation (timeout)"); + PromptResult::Timeout +} +``` + +### Step 2: Update Main to Use Prompt + +**File**: `src/main.rs` + +Add the prompt module and use it after GIF generation: + +```rust +mod prompt; + +use crate::prompt::{prompt_yes_no_with_timeout, PromptResult}; + +// In main(), after GIF generation, before the existing video generation block: + +if should_generate_gif { + time += prof! { + generate_gif(/* ... */)?; + }; +} + +// NEW: Interactive prompt for video generation +let should_generate_video = if should_generate_video { + true // User already requested video via CLI +} else if !settings.quiet() { + // Ask user if they want video (only if not in quiet mode) + match prompt_yes_no_with_timeout("🎬 Also generate MP4 video?", 10) { + PromptResult::Yes => { + check_for_mp4()?; // Verify ffmpeg is available + true + } + PromptResult::No | PromptResult::Timeout => false, + } +} else { + false // Quiet mode: don't prompt +}; + +if should_generate_video { + time += prof! { + generate_mp4(/* ... */)?; + } +} +``` + +### Step 3: Handle Quiet Mode + +In quiet mode (`-q`), skip the prompt entirely - users who want automation shouldn't be interrupted. + +### Step 4: Handle Video-Only Mode + +If `--video-only` is used, no GIF is generated and no prompt is shown (video is already being generated). + +### Step 5: Config File Support (Optional) + +Add an optional config setting to disable the prompt: + +**File**: `src/config/profile.rs` + +```rust +pub struct ProfileSettings { + // ... existing fields ... + pub prompt_video: Option, +} + +impl ProfileSettings { + pub fn prompt_video(&self) -> bool { + self.prompt_video.unwrap_or(true) // Default: show prompt + } +} +``` + +This allows users to disable the prompt in their config: +```toml +[default] +prompt-video = false +``` + +--- + +## File Changes Summary + +| File | Change Type | Description | +|------|-------------|-------------| +| `src/prompt.rs` | **New** | Interactive prompt module with timeout | +| `src/main.rs` | Modify | Import prompt module, add video prompt logic | +| `src/config/profile.rs` | Modify | Add `prompt_video` setting (optional) | + +--- + +## Edge Cases + +### 1. Quiet Mode (`-q`) +- Skip prompt entirely +- Respect original `--video` / `--video-only` flags + +### 2. Video Already Requested +- If `--video` or `--video-only` was passed, don't prompt +- Generate video as normal + +### 3. ffmpeg Not Installed +- If user says "yes" but ffmpeg is not available, show error +- Call `check_for_mp4()` after user confirms + +### 4. Piped Input / Non-TTY +- If stdin is not a terminal, skip prompt (non-interactive mode) +- Can detect with `atty::is(atty::Stream::Stdin)` + +### 5. CI/CD Environments +- The timeout ensures the process doesn't hang +- Quiet mode can be used to skip prompt + +--- + +## Dependencies + +Consider adding `atty` crate for TTY detection: + +```toml +[dependencies] +atty = "0.2" +``` + +This allows detecting if we're running interactively: +```rust +if atty::is(atty::Stream::Stdin) { + // Show prompt +} else { + // Non-interactive, skip prompt +} +``` + +--- + +## Testing Plan + +### Unit Tests + +1. **PromptResult enum**: Verify Yes/No/Timeout variants +2. **Input parsing**: "y", "Y", "yes", "n", "N", "no", "", etc. + +### Manual Tests + +1. Run `t-rec`, wait for prompt, press `y` → video generated +2. Run `t-rec`, wait for prompt, press `n` → no video +3. Run `t-rec`, wait for prompt, press Enter → no video (default) +4. Run `t-rec`, wait 10 seconds → timeout, no video +5. Run `t-rec --video` → no prompt, video generated +6. Run `t-rec --video-only` → no prompt, only video +7. Run `t-rec -q` → no prompt (quiet mode) +8. Run `echo "" | t-rec` → no prompt (non-interactive) + +--- + +## Visual Flow + +``` +Recording... +[Ctrl+D pressed] + +📋 Recording summary + ├─ fps: 4 + ├─ idle-pause: 3s + ├─ frames: 127 + └─ output: t-rec + +🎆 Applying effects (might take a bit) +💡 Tip: For smoother typing animations, try `--fps 10` or `--fps 15` + +🎉 🚀 Generating t-rec.gif + +🎬 Also generate MP4 video? [y/N] (auto-skip in 10s): y + +🎉 🚀 Generating t-rec.mp4 + +Time: 2.34s +``` + +--- + +## Future Enhancements (Out of Scope) + +- Configurable timeout duration via CLI or config +- Remember user's choice for future runs +- Prompt for other optional features (e.g., upload to cloud) diff --git a/src/generators/gif.rs b/src/generators/gif.rs index bce32a9..20d5dcc 100644 --- a/src/generators/gif.rs +++ b/src/generators/gif.rs @@ -33,7 +33,7 @@ pub fn generate_gif_with_convert( start_pause: Option, end_pause: Option, ) -> Result<()> { - println!("🎉 🚀 Generating {target}"); + println!("🎉 🚀 Generating {target}\n"); let mut cmd = Command::new(PROGRAM); cmd.arg("-loop").arg("0"); let mut delay = 0; diff --git a/src/generators/mp4.rs b/src/generators/mp4.rs index 631589f..51324df 100644 --- a/src/generators/mp4.rs +++ b/src/generators/mp4.rs @@ -41,7 +41,7 @@ pub fn generate_mp4_with_ffmpeg( tempdir: &TempDir, target: &str, ) -> Result<()> { - println!("🎉 🎬 Generating {target}"); + println!("🎬 🎉 🚀 Generating {target}"); Command::new(PROGRAM) .arg("-y") .arg("-r") diff --git a/src/main.rs b/src/main.rs index fdf5511..6f1af96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod common; mod config; mod decors; mod generators; +mod prompt; mod summary; mod tips; mod wallpapers; @@ -40,6 +41,7 @@ use crate::wallpapers::{ }; use crate::capture::{capture_thread, CaptureContext}; +use crate::prompt::{start_background_prompt, PromptResult}; use crate::utils::{sub_shell_thread, target_file, DEFAULT_EXT, MOVIE_EXT}; use anyhow::{bail, Context}; use clap::ArgMatches; @@ -205,6 +207,14 @@ fn main() -> Result<()> { let target = target_file(settings.output()); let mut time = Duration::default(); + // Start video prompt in background if we might need to ask + // This runs while GIF is being generated, so user can answer early + let video_prompt = if !should_generate_video && !settings.quiet() { + start_background_prompt("🎬 Also generate MP4 video?", 15) + } else { + None + }; + if should_generate_gif { time += prof! { generate_gif( @@ -217,6 +227,23 @@ fn main() -> Result<()> { }; } + // Determine if we should generate video: + // - If already requested via CLI/config, generate it + // - Otherwise, check the background prompt result + let should_generate_video = if should_generate_video { + true + } else if let Some(prompt) = video_prompt { + match prompt.wait() { + PromptResult::Yes => { + check_for_mp4()?; + true + } + PromptResult::No | PromptResult::Timeout => false, + } + } else { + false + }; + if should_generate_video { time += prof! { generate_mp4( diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..52a9127 --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,122 @@ +use dialoguer::Confirm; +use std::io::{self, Write}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +/// Result of a timed prompt +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum PromptResult { + Yes, + No, + Timeout, +} + +/// Handle for a background prompt that can be awaited later. +pub struct BackgroundPrompt { + handle: JoinHandle, +} + +impl BackgroundPrompt { + /// Wait for the prompt to complete and return the result. + pub fn wait(self) -> PromptResult { + self.handle.join().unwrap_or(PromptResult::No) + } +} + +/// Starts an interactive yes/no prompt in the background. +/// +/// The prompt runs in a separate thread, allowing other work to proceed +/// while waiting for user input. Call `.wait()` on the returned handle +/// to get the result. +/// +/// Returns `None` if stdin is not interactive (piped/redirected). +pub fn start_background_prompt(question: &str, timeout_secs: u64) -> Option { + if !is_interactive() { + return None; + } + + let question = question.to_string(); + let handle = thread::spawn(move || run_prompt(&question, timeout_secs)); + + Some(BackgroundPrompt { handle }) +} + +/// Runs the interactive prompt with countdown. +fn run_prompt(question: &str, timeout_secs: u64) -> PromptResult { + let (tx, rx): (Sender, Receiver) = mpsc::channel(); + + // Spawn thread to run dialoguer prompt + let question_clone = question.to_string(); + thread::spawn(move || { + let result = Confirm::new() + .with_prompt(&question_clone) + .default(false) + .interact(); + + if let Ok(confirmed) = result { + let _ = tx.send(confirmed); + } + }); + + // Small delay to let dialoguer render its prompt first + thread::sleep(Duration::from_millis(50)); + + // Countdown loop - use carriage return to update in place on same line + for remaining in (0..=timeout_secs).rev() { + // Save cursor, move to column 0 of next line, print countdown, restore cursor + // This prints below dialoguer without interfering with it + print!("\x1b[s\n\r(auto-skip in {}s) \x1b[u", remaining); + io::stdout().flush().unwrap(); + + if remaining == 0 { + // Move down and print timeout message + println!("\n\nSkipping video generation (timeout)"); + return PromptResult::Timeout; + } + + // Check for input with 1-second timeout + match rx.recv_timeout(Duration::from_secs(1)) { + Ok(confirmed) => { + // Clear the countdown line (move down, clear, move back up) + print!("\n\r\x1b[2K\x1b[1A"); + io::stdout().flush().unwrap(); + return if confirmed { + PromptResult::Yes + } else { + PromptResult::No + }; + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // Continue countdown + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + print!("\n\r\x1b[2K\x1b[1A"); + io::stdout().flush().unwrap(); + return PromptResult::No; + } + } + } + + PromptResult::Timeout +} + +/// Check if stdin is connected to an interactive terminal. +/// +/// Returns false if input is piped or redirected. +fn is_interactive() -> bool { + use std::io::IsTerminal; + std::io::stdin().is_terminal() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prompt_result_enum() { + assert_ne!(PromptResult::Yes, PromptResult::No); + assert_ne!(PromptResult::No, PromptResult::Timeout); + assert_ne!(PromptResult::Yes, PromptResult::Timeout); + } +}