diff --git a/.gitignore b/.gitignore index a5ab680..83abc03 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,7 @@ .idea .test-env t-rec*.gif +t-rec*.mp4 +requirements/ # End of https://www.toptal.com/developers/gitignore/api/rust diff --git a/Cargo.lock b/Cargo.lock index 9aca0df..a6d89e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -127,7 +128,18 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -402,6 +414,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "humantime" version = "2.3.0" @@ -782,12 +800,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "syn" version = "2.0.111" diff --git a/Cargo.toml b/Cargo.toml index dd16be3..b6da7ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,8 @@ dialoguer = { version = "0.12", default-features = false } [dependencies.clap] version = "4.5" -features = ["cargo"] +features = ["cargo", "derive", "help", "color", "std"] +default-features = false [dependencies.image] version = "0.25" diff --git a/README.md b/README.md index 8d3a4bd..9f969c6 100644 --- a/README.md +++ b/README.md @@ -134,51 +134,56 @@ t-rec /bin/sh ### Full Options ```text -t-rec 0.7.6 -Sven Assmann Blazingly fast terminal recorder that generates animated gif images for the web written in rust. -Usage: t-rec [OPTIONS] [shell or program to launch] +Usage: Arguments: - [shell or program to launch] If you want to start a different program than $SHELL you can - pass it here. For example '/bin/sh' + [PROGRAM] Shell or program to launch. Defaults to $SHELL Options: - -v, --verbose Enable verbose insights for the curious - -q, --quiet Quiet mode, suppresses the banner: - 'Press Ctrl+D to end recording' - -m, --video Generates additionally to the gif a mp4 video of the recording - -M, --video-only Generates only a mp4 video and not gif - -d, --decor Decorates the animation with certain, mostly border effects - [default: none] [possible values: shadow, none] - -b, --bg Background color when decors are used [default: transparent] - [possible values: white, black, transparent] - -n, --natural If you want a very natural typing experience and disable the idle - detection and sampling optimization - -l, --ls-win If you want to see a list of windows available for recording by - their id, you can set env var 'WINDOWID' or `--win-id` to record - this specific window only - -w, --win-id Window Id (see --ls-win) that should be captured, instead of - the current terminal - -e, --end-pause to specify the pause time at the end of the animation, that time - the gif will show the last frame - -s, --start-pause to specify the pause time at the start of the animation, that time - the gif will show the first frame - -i, --idle-pause to preserve natural pauses up to a maximum duration by overriding - idle detection. Can enhance readability. [default: 3s] - -o, --output to specify the output file (without extension) [default: t-rec] - -f, --fps <4-15> Capture framerate. Higher = smoother animations but larger - files [default: 4] - -p, --wallpaper Wallpaper background. Use 'ventura' for built-in, or provide - a path to a custom image (PNG, JPEG, TGA) - --wallpaper-padding <1-500> Padding in pixels around the recording when using --wallpaper - [default: 60] - --profile Use a named profile from the config file - --init-config Create a starter config file at ~/.config/t-rec/config.toml - --list-profiles List available profiles from the config file - -h, --help Print help - -V, --version Print version + -v, --verbose + Enable verbose insights for the curious + -q, --quiet + Quiet mode, suppresses the banner: 'Press Ctrl+D to end recording' + -m, --video + Generates additionally to the gif a mp4 video of the recording + -M, --video-only + Generates only a mp4 video and not gif + -d, --decor + Decorates the animation with certain, mostly border effects [default: none] [possible values: shadow, none] + -p, --wallpaper + Wallpaper background. Use 'ventura' for built-in, or provide a path to a custom image (PNG, JPEG, TGA) + --wallpaper-padding + Padding in pixels around the recording when using --wallpaper [default: 60] + -b, --bg + Background color when decors are used [default: transparent] [possible values: white, black, transparent] + -n, --natural + If you want a very natural typing experience and disable the idle detection and sampling optimization + -l, --ls-win + If you want to see a list of windows available for recording by their id [aliases: --ls] + -w, --win-id + Window Id (see --ls-win) that should be captured, instead of the current terminal + -e, --end-pause + Pause time at the end of the animation (e.g., "2s", "500ms") + -s, --start-pause + Pause time at the start of the animation (e.g., "1s", "200ms") + -i, --idle-pause + Max idle time before optimization kicks in. Can enhance readability [default: 3s] + -o, --output + Output file without extension [default: t-rec] + -f, --fps + Capture framerate, 4-15 fps. Higher = smoother but larger files [default: 4] + --profile + Use a named profile from the config file + --init-config + Create a starter config file at `~/.config/t-rec/config.toml` (linux) or `~/Library/Application Support/t-rec/config.toml` (macOS) + --list-profiles + List available profiles from the config file + -h, --help + Print help + -V, --version + Print version ``` ### Configuration File @@ -200,7 +205,8 @@ t-rec --profile demo **Config file locations** (searched in order): 1. `./t-rec.toml` (project-local) -2. `~/.config/t-rec/config.toml` (Linux/macOS) +2. `~/.config/t-rec/config.toml` (Linux) +2. `~/Library/Application Support/t-rec/config.toml` (macOS) 3. `%APPDATA%\t-rec\config.toml` (Windows) **Example config file:** diff --git a/features/custom-wallpaper.md b/features/custom-wallpaper.md deleted file mode 100644 index 1e4536c..0000000 --- a/features/custom-wallpaper.md +++ /dev/null @@ -1,394 +0,0 @@ -# Implementation Plan: Custom Wallpaper Support - -## Overview - -Extend the existing `--wallpaper` feature to support user-provided custom wallpaper images. Users can specify their own wallpaper file path, with strict validation to ensure the wallpaper meets resolution requirements before recording starts. - -**Reference**: [GitHub Issue #225](https://github.com/sassman/t-rec-rs/issues/225) - -## Requirements from Issue #225 - -1. **Default wallpaper**: Provided by t-rec, available offline (already implemented with `ventura`) -2. **Custom wallpaper**: Users can specify their own wallpaper via CLI argument -3. **Supported formats**: PNG, JPEG, and TGA -4. **Resolution validation**: Wallpaper must be at least n-pixel wider and higher than the recorded terminal, where n must match the 2x the padding value (default 60px), so for left side one padding, and righ side another padding, that makes 2x60 = 120px total extra width and height for the default case -5. **Pre-recording validation**: Resolution must be validated BEFORE recording starts; if insufficient, inform user and stop -6. **Offline operation**: No internet download required - -## Current State (Already Implemented) - -- `--wallpaper ventura` / `-p ventura`: Built-in Ventura wallpaper -- `--wallpaper-padding <1-500>`: Configurable padding (default: 60px) -- `apply_wallpaper_effect()`: Generic function accepting any `&DynamicImage` - ---- - -## Implementation Steps - -### Step 1: Update CLI Arguments - -**File**: `src/cli.rs` - -**Current**: -```rust -.arg( - Arg::new("wallpaper") - .value_parser(["ventura"]) - .required(false) - .short('p') - .long("wallpaper") - .help("...") -) -``` - -**New**: Change `--wallpaper` to accept either a preset name OR a file path: - -```rust -.arg( - Arg::new("wallpaper") - .value_parser(NonEmptyStringValueParser::new()) - .required(false) - .short('p') - .long("wallpaper") - .help("Wallpaper background. Use 'ventura' for built-in, or provide a path to a custom image (PNG, JPEG, TGA)") -) -``` - -**Validation logic** (in main.rs, not cli.rs): -- If value is `"ventura"` → use built-in -- Otherwise → treat as file path and validate - -### Step 2: Create Wallpaper Validation Module - -**File**: `src/wallpapers/validation.rs` (new) - -```rust -use std::path::Path; -use image::{DynamicImage, GenericImageView, ImageReader}; -use anyhow::{Context, Result, bail}; - -/// Supported wallpaper formats -const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "tga"]; - -/// Validate and load a custom wallpaper from a file path -/// -/// # Validation checks: -/// 1. File exists and is readable -/// 2. File extension is supported (png, jpg, jpeg, tga) -/// 3. File is a valid image that can be decoded -/// 4. Resolution is sufficient for the terminal size + padding -/// (wallpaper must be at least terminal_size + 2*padding in each dimension) -/// -/// # Security considerations: -/// - Path traversal: We only read the file, no writes -/// - File size: image crate handles memory allocation -/// - Format validation: Only decode known safe formats -pub fn load_and_validate_wallpaper( - path: &Path, - terminal_width: u32, - terminal_height: u32, - padding: u32, -) -> Result { - // 1. Check file exists - if !path.exists() { - bail!("Wallpaper file not found: {}", path.display()); - } - - if !path.is_file() { - bail!("Wallpaper path is not a file: {}", path.display()); - } - - // 2. Validate file extension - let extension = path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_default(); - - if !SUPPORTED_EXTENSIONS.contains(&extension.as_str()) { - bail!( - "Unsupported wallpaper format '{}'. Supported formats: {}", - extension, - SUPPORTED_EXTENSIONS.join(", ") - ); - } - - // 3. Load and decode the image - let wallpaper = ImageReader::open(path) - .with_context(|| format!("Failed to open wallpaper file: {}", path.display()))? - .with_guessed_format() - .with_context(|| "Failed to detect wallpaper image format")? - .decode() - .with_context(|| format!("Failed to decode wallpaper image: {}", path.display()))?; - - // 4. Validate resolution - // Wallpaper must be at least terminal_size + 2*padding (padding on each side) - let (wp_width, wp_height) = wallpaper.dimensions(); - let min_width = terminal_width + (padding * 2); - let min_height = terminal_height + (padding * 2); - - if wp_width < min_width || wp_height < min_height { - bail!( - "Wallpaper resolution {}x{} is too small.\n\ - Required: at least {}x{} (terminal {}x{} + {}px padding on each side).\n\ - Please use a larger wallpaper image.", - wp_width, wp_height, - min_width, min_height, - terminal_width, terminal_height, - padding - ); - } - - Ok(wallpaper) -} - -/// Check if a wallpaper value is a built-in preset or a custom path -pub fn is_builtin_wallpaper(value: &str) -> bool { - matches!(value.to_lowercase().as_str(), "ventura") -} -``` - -### Step 3: Update Main Recording Flow - -**File**: `src/main.rs` - -**Key change**: Validate wallpaper resolution BEFORE starting the recording. - -```rust -fn main() -> Result<()> { - // ... existing setup ... - - // Parse wallpaper settings early - let wallpaper_config = if let Some(wp_value) = args.get_one::("wallpaper") { - let padding = *args.get_one::("wallpaper-padding").unwrap(); - Some(parse_wallpaper_config(wp_value, padding, win_id, &api)?) - } else { - None - }; - - // ... rest of recording logic ... - - // Apply wallpaper effect (after recording completes) - if let Some(config) = &wallpaper_config { - apply_wallpaper_effect( - &time_codes.lock().unwrap(), - tempdir.lock().unwrap().borrow(), - &config.wallpaper, - config.padding, - ); - } -} - -struct WallpaperConfig { - wallpaper: DynamicImage, - padding: u32, -} - -fn parse_wallpaper_config( - value: &str, - padding: u32, - win_id: WindowId, - api: &impl PlatformApi, -) -> Result { - // Get terminal window dimensions for validation - let (terminal_width, terminal_height) = api.get_window_dimensions(win_id)?; - - let wallpaper = if is_builtin_wallpaper(value) { - match value.to_lowercase().as_str() { - "ventura" => get_ventura_wallpaper().clone(), - _ => bail!("Unknown built-in wallpaper: {}", value), - } - } else { - // Custom wallpaper path - validate before recording - let path = Path::new(value); - load_and_validate_wallpaper(path, terminal_width, terminal_height, padding)? - }; - - Ok(WallpaperConfig { wallpaper, padding }) -} -``` - -### Step 4: Add Window Dimensions to Platform API - -**File**: `src/common/platform_api.rs` - -Add method to get window dimensions for pre-validation: - -```rust -pub trait PlatformApi { - // ... existing methods ... - - /// Get the dimensions of a window (width, height) - fn get_window_dimensions(&self, win_id: WindowId) -> Result<(u32, u32)>; -} -``` - -**macOS implementation** (`src/macos/mod.rs`): -```rust -fn get_window_dimensions(&self, win_id: WindowId) -> Result<(u32, u32)> { - // Use CGWindowListCopyWindowInfo or similar to get window bounds - // Extract width and height from the bounds -} -``` - -**Linux implementation** (`src/linux/mod.rs`): -```rust -fn get_window_dimensions(&self, win_id: WindowId) -> Result<(u32, u32)> { - // Use X11 XGetWindowAttributes or similar -} -``` - -### Step 5: Update Cargo.toml - -**File**: `Cargo.toml` - -Ensure PNG support is enabled (already have jpeg, tga, bmp): - -```toml -[dependencies.image] -version = "0.25" -default-features = false -features = ["bmp", "tga", "jpeg", "png"] -``` - -### Step 6: Update Wallpapers Module - -**File**: `src/wallpapers/mod.rs` - -```rust -mod validation; -mod ventura; - -pub use validation::{is_builtin_wallpaper, load_and_validate_wallpaper}; -pub use ventura::{apply_ventura_wallpaper_effect, get_ventura_wallpaper}; - -// ... existing apply_wallpaper_effect function ... -``` - ---- - -## Security Considerations - -### Input Validation - -1. **Path Traversal**: - - Risk: User provides path like `../../etc/passwd` - - Mitigation: We only READ the file, never write. The image crate will fail to decode non-image files. - - Additional: Consider canonicalizing the path and warning if it's outside expected directories. - -2. **File Extension Validation**: - - Risk: User provides malicious file with fake extension - - Mitigation: We validate extension first, then let the image crate validate the actual format during decode. If format doesn't match, decode fails safely. - -3. **Memory Exhaustion**: - - Risk: User provides extremely large image - - Mitigation: The image crate handles allocation. There's no upper bound check, but users loading huge images would impact their own system. - -4. **Symlink Following**: - - Risk: Symlink to sensitive file - - Mitigation: We only read as image, non-image files fail to decode. Consider using `fs::metadata()` to check for symlinks if paranoid. - -5. **Format-Specific Vulnerabilities**: - - Risk: Malformed PNG/JPEG could exploit decoder bugs - - Mitigation: Use well-maintained `image` crate from Rust ecosystem. Keep dependencies updated. - -### Error Messages - -- Never include full system paths in error messages if they could reveal sensitive information -- Provide actionable error messages (what went wrong, what to do) - ---- - -## File Changes Summary - -| File | Change Type | Description | -|------|-------------|-------------| -| `src/cli.rs` | Modify | Change `--wallpaper` to accept string (preset or path) | -| `src/wallpapers/validation.rs` | **New** | Wallpaper loading and validation logic | -| `src/wallpapers/mod.rs` | Modify | Export validation functions | -| `src/main.rs` | Modify | Add pre-recording wallpaper validation | -| `src/common/platform_api.rs` | Modify | Add `get_window_dimensions()` trait method | -| `src/macos/mod.rs` | Modify | Implement `get_window_dimensions()` | -| `src/linux/mod.rs` | Modify | Implement `get_window_dimensions()` | -| `Cargo.toml` | Modify | Add `png` feature to image crate | - ---- - -## Testing Plan - -### Unit Tests - -1. **Extension validation**: Test all supported extensions (png, jpg, jpeg, tga) -2. **Extension rejection**: Test unsupported extensions (gif, webp, bmp, svg) -3. **Case insensitivity**: Test `PNG`, `Png`, `png` -4. **Built-in detection**: Test `is_builtin_wallpaper("ventura")` returns true - -### Integration Tests - -1. **Valid custom wallpaper**: Provide valid PNG, verify it loads -2. **Non-existent file**: Verify clear error message -3. **Too small wallpaper**: Verify resolution error with helpful message -4. **Wrong format**: Provide text file with .png extension, verify decode error - -### Manual Tests - -1. **Happy path**: `t-rec --wallpaper ~/Pictures/my-wallpaper.png` -2. **Built-in still works**: `t-rec --wallpaper ventura` -3. **Custom with padding**: `t-rec --wallpaper ~/wp.jpg --wallpaper-padding 100` -4. **Error case**: Try with wallpaper smaller than terminal - ---- - -## CLI Usage Examples - -```bash -# Built-in wallpaper (existing behavior) -t-rec --wallpaper ventura -t-rec -p ventura - -# Custom wallpaper from file path -t-rec --wallpaper ~/Pictures/my-wallpaper.png -t-rec -p /path/to/wallpaper.jpg - -# Custom wallpaper with adjusted padding -t-rec --wallpaper ~/wp.png --wallpaper-padding 100 - -# Relative path -t-rec --wallpaper ./backgrounds/custom.tga -``` - ---- - -## Error Messages - -### File not found -``` -Error: Wallpaper file not found: /path/to/wallpaper.png -``` - -### Unsupported format -``` -Error: Unsupported wallpaper format 'gif'. Supported formats: png, jpg, jpeg, tga -``` - -### Resolution too small -``` -Error: Wallpaper resolution 800x600 is too small. -Required: at least 1144x888 (terminal 1024x768 + 60px padding on each side). -Please use a larger wallpaper image. -``` - -### Decode failure -``` -Error: Failed to decode wallpaper image: /path/to/file.png -Caused by: Invalid PNG signature -``` - ---- - -## Future Enhancements (Out of Scope) - -- **Wallpaper scaling**: Automatically scale wallpaper if slightly too small -- **Aspect ratio handling**: Crop or letterbox wallpapers with different aspect ratios -- **URL support**: Download wallpaper from URL (explicitly not wanted per issue - offline operation) -- **Wallpaper caching**: Cache decoded custom wallpapers for repeated use diff --git a/features/interactive-video-prompt.md b/features/interactive-video-prompt.md deleted file mode 100644 index 8f66914..0000000 --- a/features/interactive-video-prompt.md +++ /dev/null @@ -1,305 +0,0 @@ -# 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/cli.rs b/src/cli.rs index 8522a5d..691d5cb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,177 +1,126 @@ -use clap::builder::NonEmptyStringValueParser; -use clap::{crate_authors, crate_description, crate_version, Arg, ArgAction, ArgMatches, Command}; - -pub fn launch() -> ArgMatches { - Command::new("t-rec") - .allow_missing_positional(true) - .version(crate_version!()) - .author(crate_authors!()) - .about(crate_description!()) - .arg( - Arg::new("verbose") - .action(ArgAction::SetTrue) - .short('v') - .long("verbose") - .required(false) - .help("Enable verbose insights for the curious") - ) - .arg( - Arg::new("quiet") - .action(ArgAction::SetTrue) - .short('q') - .long("quiet") - .required(false) - .help("Quiet mode, suppresses the banner: 'Press Ctrl+D to end recording'") - ) - .arg( - Arg::new("video") - .action(ArgAction::SetTrue) - .short('m') - .long("video") - .required(false) - .help("Generates additionally to the gif a mp4 video of the recording") - ) - .arg( - Arg::new("video-only") - .action(ArgAction::SetTrue) - .short('M') - .long("video-only") - .required(false) - .conflicts_with("video") - .help("Generates only a mp4 video and not gif") - ) - .arg( - Arg::new("decor") - .value_parser(["shadow", "none"]) - .default_value("none") - .required(false) - .short('d') - .long("decor") - .help("Decorates the animation with certain, mostly border effects") - ) - .arg( - Arg::new("wallpaper") - .value_parser(NonEmptyStringValueParser::new()) - .required(false) - .short('p') - .long("wallpaper") - .help("Wallpaper background. Use 'ventura' for built-in, or provide a path to a custom image (PNG, JPEG, TGA)") - ) - .arg( - Arg::new("wallpaper-padding") - .value_parser(clap::value_parser!(u32).range(1..=500)) - .default_value("60") - .required(false) - .long("wallpaper-padding") - .help("Padding in pixels around the recording when using --wallpaper (1-500, default: 60)") - ) - .arg( - Arg::new("bg") - .value_parser(["white", "black", "transparent"]) - .default_value("transparent") - .required(false) - .short('b') - .long("bg") - .help("Background color when decors are used") - ) - .arg( - Arg::new("natural-mode") - .action(ArgAction::SetTrue) - .value_name("natural") - .required(false) - .short('n') - .long("natural") - .help("If you want a very natural typing experience and disable the idle detection and sampling optimization") - ) - .arg( - Arg::new("list-windows") - .action(ArgAction::SetTrue) - .value_name("list all visible windows with name and id") - .required(false) - .short('l') - .long("ls-win") - .long("ls") - .help("If you want to see a list of windows available for recording by their id, you can set env var 'WINDOWID' or `--win-id` to record this specific window only"), - ) - .arg( - Arg::new("win-id") - .value_parser(clap::value_parser!(u64)) - .short('w') - .long("win-id") - .required(false) - .help("Window Id (see --ls-win) that should be captured, instead of the current terminal") - ) - .arg( - Arg::new("end-pause") - .value_parser(NonEmptyStringValueParser::new()) - .value_name("s | ms | m") - .required(false) - .short('e') - .long("end-pause") - .help("to specify the pause time at the end of the animation, that time the gif will show the last frame"), - ) - .arg( - Arg::new("start-pause") - .value_parser(NonEmptyStringValueParser::new()) - .value_name("s | ms | m") - .required(false) - .short('s') - .long("start-pause") - .help("to specify the pause time at the start of the animation, that time the gif will show the first frame"), - ) - .arg( - Arg::new("idle-pause") - .value_parser(NonEmptyStringValueParser::new()) - .value_name("s | ms | m") - .required(false) - .short('i') - .long("idle-pause") - .default_value("3s") - .help("to preserve natural pauses up to a maximum duration by overriding idle detection. Can enhance readability."), - ) - .arg( - Arg::new("file") - .value_parser(NonEmptyStringValueParser::new()) - .required(false) - .short('o') - .long("output") - .default_value("t-rec") - .help("to specify the output file (without extension)"), - ) - .arg( - Arg::new("fps") - .value_parser(clap::value_parser!(u8).range(4..=15)) - .default_value("4") - .required(false) - .short('f') - .long("fps") - .help("Capture framerate, 4-15 fps. Higher = smoother animations but larger files"), - ) - .arg( - Arg::new("program") - .value_name("shell or program to launch") - .value_parser(NonEmptyStringValueParser::new()) - .required(false) - .help("If you want to start a different program than $SHELL you can pass it here. For example '/bin/sh'"), - ) - .arg( - Arg::new("profile") - .value_parser(NonEmptyStringValueParser::new()) - .required(false) - .long("profile") - .help("Use a named profile from the config file"), - ) - .arg( - Arg::new("init-config") - .action(ArgAction::SetTrue) - .long("init-config") - .help("Create a starter config file at ~/.config/t-rec/config.toml"), - ) - .arg( - Arg::new("list-profiles") - .action(ArgAction::SetTrue) - .long("list-profiles") - .help("List available profiles from the config file"), - ) - .get_matches() +//! Parse command line arguments. +//! +//! # Why no `default_value` on CliArgs? +//! +//! You might be tempted to use clap's `default_value` or `default_value_t` attributes. **Don't!** +//! +//! The problem: clap applies defaults *before* we can check if the user actually provided a value. +//! This breaks our config precedence model where config/profile values should override CLI defaults, +//! but explicit CLI args should override config. +//! +//! Example of what goes wrong with `default_value`: +//! - Config file has `fps = 10` +//! - User runs `t-rec` (no `--fps` flag) +//! - With `default_value_t = 4`: clap sets `fps = 4`, we can't tell user didn't specify it, +//! config's `fps = 10` is ignored +//! - With `Option` (no default): clap sets `fps = None`, we know to use config's `fps = 10` +//! +//! By using `Option` without defaults, `None` clearly means "user didn't specify, use config +//! or fall back to default". The actual defaults are applied later in `ProfileSettings` accessor +//! methods, after config merging. +//! +//! Note: The default values shown in help text (e.g., `[default: 4]`) must be kept in sync with +//! the constants in `config::defaults` module. This duplication is unfortunate but necessary +//! because Rust's `concat!` macro only works with literal strings, not const references. + +use clap::Parser; + +use crate::config::{resolve_settings, ProfileSettings}; + +/// Blazingly fast terminal recorder that generates animated gif images for the web +#[derive(Parser, Debug)] +#[command(author, version, about)] +pub struct CliArgs { + /// Enable verbose insights for the curious + #[arg(short, long)] + pub verbose: bool, + + /// Quiet mode, suppresses the banner: 'Press Ctrl+D to end recording' + #[arg(short, long)] + pub quiet: bool, + + /// Generates additionally to the gif a mp4 video of the recording + #[arg(short = 'm', long)] + pub video: bool, + + /// Generates only a mp4 video and not gif + #[arg(short = 'M', long = "video-only", conflicts_with = "video")] + pub video_only: bool, + + /// Decorates the animation with certain, mostly border effects [default: none] + #[arg(short, long, value_parser = ["shadow", "none"])] + pub decor: Option, + + /// Wallpaper background. Use 'ventura' for built-in, or provide a path to a custom image (PNG, JPEG, TGA) + #[arg(short = 'p', long)] + pub wallpaper: Option, + + /// Padding in pixels around the recording when using --wallpaper [default: 60] + #[arg(long = "wallpaper-padding", value_parser = clap::value_parser!(u32).range(1..=500))] + pub wallpaper_padding: Option, + + /// Background color when decors are used [default: transparent] + #[arg(short, long, value_parser = ["white", "black", "transparent"])] + pub bg: Option, + + /// If you want a very natural typing experience and disable the idle detection and sampling optimization + #[arg(short, long = "natural")] + pub natural: bool, + + /// If you want to see a list of windows available for recording by their id + #[arg(short = 'l', long = "ls-win", visible_alias = "ls")] + pub list_windows: bool, + + /// Window Id (see --ls-win) that should be captured, instead of the current terminal + #[arg(short = 'w', long = "win-id")] + pub win_id: Option, + + /// Pause time at the end of the animation (e.g., "2s", "500ms") + #[arg(short = 'e', long = "end-pause")] + pub end_pause: Option, + + /// Pause time at the start of the animation (e.g., "1s", "200ms") + #[arg(short = 's', long = "start-pause")] + pub start_pause: Option, + + /// Max idle time before optimization kicks in. Can enhance readability [default: 3s] + #[arg(short = 'i', long = "idle-pause")] + pub idle_pause: Option, + + /// Output file without extension [default: t-rec] + #[arg(short = 'o', long = "output")] + pub output: Option, + + /// Capture framerate, 4-15 fps. Higher = smoother but larger files [default: 4] + #[arg(short = 'f', long, value_parser = clap::value_parser!(u8).range(4..=15))] + pub fps: Option, + + /// Shell or program to launch. Defaults to $SHELL + #[arg()] + pub program: Option, + + // --- Config-related args (not part of recording settings) --- + /// Use a named profile from the config file + #[arg(long)] + pub profile: Option, + + /// Create a starter config file at `~/.config/t-rec/config.toml` (linux) or `~/Library/Application Support/t-rec/config.toml` (macOS) + #[arg(long = "init-config")] + pub init_config: bool, + + /// List available profiles from the config file + #[arg(long = "list-profiles")] + pub list_profiles: bool, +} + +pub fn launch() -> CliArgs { + CliArgs::parse() +} + +/// Load config and resolve settings: config defaults -> profile -> CLI args +pub fn resolve_profiled_settings(args: &CliArgs) -> anyhow::Result { + let config = crate::config::load_config()?; + let profile_name = args.profile.as_deref(); + + resolve_settings(config.as_ref(), profile_name, args) } diff --git a/src/config/defaults.rs b/src/config/defaults.rs new file mode 100644 index 0000000..6a9dcb7 --- /dev/null +++ b/src/config/defaults.rs @@ -0,0 +1,15 @@ +//! Default values shared between CLI and config +//! +//! These constants are the single source of truth for all default values. +//! They are used in `ProfileSettings` accessor methods to apply defaults after config merging. +//! +//! Note: The help text in `cli.rs` also shows these defaults (e.g., "[default: 4]"). +//! Unfortunately, these must be kept in sync manually because Rust's `concat!` macro +//! only works with literal strings, not const references. + +pub const FPS: u8 = 4; +pub const DECOR: &str = "none"; +pub const BG: &str = "transparent"; +pub const WALLPAPER_PADDING: u32 = 60; +pub const IDLE_PAUSE: &str = "3s"; +pub const OUTPUT: &str = "t-rec"; diff --git a/src/config/mod.rs b/src/config/mod.rs index 4791f2f..ce3e485 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,5 @@ mod commands; +pub mod defaults; mod file; mod init; mod profile; diff --git a/src/config/profile.rs b/src/config/profile.rs index a2a3c4a..a0d2f29 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -1,10 +1,11 @@ use anyhow::Result; -use clap::ArgMatches; use serde::Deserialize; +use super::defaults; use super::ConfigFile; +use crate::cli::CliArgs; -/// Settings that can be specified in a profile +/// Settings that can be specified in a profile (all optional for merging) #[derive(Debug, Deserialize, Default, Clone)] #[serde(rename_all = "kebab-case")] pub struct ProfileSettings { @@ -27,110 +28,27 @@ pub struct ProfileSettings { impl ProfileSettings { /// Merge another profile into this one (other takes precedence) pub fn merge(&mut self, other: &ProfileSettings) { - if other.verbose.is_some() { - self.verbose = other.verbose; - } - if other.quiet.is_some() { - self.quiet = other.quiet; - } - if other.video.is_some() { - self.video = other.video; - } - if other.video_only.is_some() { - self.video_only = other.video_only; - } - if other.decor.is_some() { - self.decor = other.decor.clone(); - } - if other.wallpaper.is_some() { - self.wallpaper = other.wallpaper.clone(); - } - if other.wallpaper_padding.is_some() { - self.wallpaper_padding = other.wallpaper_padding; - } - if other.bg.is_some() { - self.bg = other.bg.clone(); - } - if other.natural.is_some() { - self.natural = other.natural; - } - if other.end_pause.is_some() { - self.end_pause = other.end_pause.clone(); - } - if other.start_pause.is_some() { - self.start_pause = other.start_pause.clone(); - } - if other.idle_pause.is_some() { - self.idle_pause = other.idle_pause.clone(); - } - if other.output.is_some() { - self.output = other.output.clone(); - } - if other.fps.is_some() { - self.fps = other.fps; - } - } - - /// Apply CLI arguments on top of config settings - /// CLI args always win over config values - pub fn apply_cli_args(&mut self, args: &ArgMatches) { - // Flags - only override if explicitly set on CLI - if args.get_flag("verbose") { - self.verbose = Some(true); - } - if args.get_flag("quiet") { - self.quiet = Some(true); - } - if args.get_flag("video") { - self.video = Some(true); - } - if args.get_flag("video-only") { - self.video_only = Some(true); - } - if args.get_flag("natural-mode") { - self.natural = Some(true); - } - - // Values - only override if provided on CLI (not default values) - if args.value_source("decor") == Some(clap::parser::ValueSource::CommandLine) { - if let Some(v) = args.get_one::("decor") { - self.decor = Some(v.clone()); - } - } - if let Some(v) = args.get_one::("wallpaper") { - self.wallpaper = Some(v.clone()); - } - if args.value_source("wallpaper-padding") == Some(clap::parser::ValueSource::CommandLine) { - if let Some(v) = args.get_one::("wallpaper-padding") { - self.wallpaper_padding = Some(*v); - } - } - if args.value_source("bg") == Some(clap::parser::ValueSource::CommandLine) { - if let Some(v) = args.get_one::("bg") { - self.bg = Some(v.clone()); - } - } - if let Some(v) = args.get_one::("end-pause") { - self.end_pause = Some(v.clone()); - } - if let Some(v) = args.get_one::("start-pause") { - self.start_pause = Some(v.clone()); - } - if args.value_source("idle-pause") == Some(clap::parser::ValueSource::CommandLine) { - if let Some(v) = args.get_one::("idle-pause") { - self.idle_pause = Some(v.clone()); - } - } - if args.value_source("file") == Some(clap::parser::ValueSource::CommandLine) { - if let Some(v) = args.get_one::("file") { - self.output = Some(v.clone()); - } - } - if args.value_source("fps") == Some(clap::parser::ValueSource::CommandLine) { - if let Some(v) = args.get_one::("fps") { - self.fps = Some(*v); - } - } + macro_rules! merge_field { + ($field:ident) => { + if other.$field.is_some() { + self.$field = other.$field.clone(); + } + }; + } + merge_field!(verbose); + merge_field!(quiet); + merge_field!(video); + merge_field!(video_only); + merge_field!(decor); + merge_field!(wallpaper); + merge_field!(wallpaper_padding); + merge_field!(bg); + merge_field!(natural); + merge_field!(end_pause); + merge_field!(start_pause); + merge_field!(idle_pause); + merge_field!(output); + merge_field!(fps); } /// Get final values with defaults applied @@ -150,23 +68,47 @@ impl ProfileSettings { self.natural.unwrap_or(false) } pub fn decor(&self) -> &str { - self.decor.as_deref().unwrap_or("none") + self.decor.as_deref().unwrap_or(defaults::DECOR) } pub fn bg(&self) -> &str { - self.bg.as_deref().unwrap_or("transparent") + self.bg.as_deref().unwrap_or(defaults::BG) } pub fn wallpaper_padding(&self) -> u32 { - self.wallpaper_padding.unwrap_or(60) + self.wallpaper_padding + .unwrap_or(defaults::WALLPAPER_PADDING) } pub fn idle_pause(&self) -> &str { - self.idle_pause.as_deref().unwrap_or("3s") + self.idle_pause.as_deref().unwrap_or(defaults::IDLE_PAUSE) } pub fn output(&self) -> &str { - self.output.as_deref().unwrap_or("t-rec") + self.output.as_deref().unwrap_or(defaults::OUTPUT) } - /// Get fps value (default: 4, must be kept in sync with CLI default) pub fn fps(&self) -> u8 { - self.fps.unwrap_or(4) + self.fps.unwrap_or(defaults::FPS) + } +} + +impl From<&CliArgs> for ProfileSettings { + fn from(args: &CliArgs) -> Self { + ProfileSettings { + // Flags: only set if true (otherwise None lets config win) + verbose: if args.verbose { Some(true) } else { None }, + quiet: if args.quiet { Some(true) } else { None }, + video: if args.video { Some(true) } else { None }, + video_only: if args.video_only { Some(true) } else { None }, + natural: if args.natural { Some(true) } else { None }, + + // Values: None if user didn't provide, Some if they did + decor: args.decor.clone(), + wallpaper: args.wallpaper.clone(), + wallpaper_padding: args.wallpaper_padding, + bg: args.bg.clone(), + end_pause: args.end_pause.clone(), + start_pause: args.start_pause.clone(), + idle_pause: args.idle_pause.clone(), + output: args.output.clone(), + fps: args.fps, + } } } @@ -184,6 +126,7 @@ pub fn expand_home(value: &str) -> String { pub fn resolve_settings( config: Option<&ConfigFile>, profile_name: Option<&str>, + args: &CliArgs, ) -> Result { let mut settings = ProfileSettings::default(); @@ -213,6 +156,9 @@ pub fn resolve_settings( } } + // CLI args override everything + settings.merge(&ProfileSettings::from(args)); + Ok(settings) } @@ -260,11 +206,11 @@ mod tests { assert!(!settings.verbose()); assert!(!settings.quiet()); - assert_eq!(settings.decor(), "none"); - assert_eq!(settings.bg(), "transparent"); - assert_eq!(settings.wallpaper_padding(), 60); - assert_eq!(settings.idle_pause(), "3s"); - assert_eq!(settings.output(), "t-rec"); - assert_eq!(settings.fps(), 4); + assert_eq!(settings.decor(), defaults::DECOR); + assert_eq!(settings.bg(), defaults::BG); + assert_eq!(settings.wallpaper_padding(), defaults::WALLPAPER_PADDING); + assert_eq!(settings.idle_pause(), defaults::IDLE_PAUSE); + assert_eq!(settings.output(), defaults::OUTPUT); + assert_eq!(settings.fps(), defaults::FPS); } } diff --git a/src/main.rs b/src/main.rs index 6f1af96..b2ef0c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,12 +25,10 @@ use crate::macos::*; #[cfg(target_os = "windows")] use crate::windows::*; -use crate::cli::launch; +use crate::cli::{launch, resolve_profiled_settings, CliArgs}; use crate::common::utils::{clear_screen, parse_delay, HumanReadable}; use crate::common::{Margin, PlatformApi}; -use crate::config::{ - expand_home, handle_init_config, handle_list_profiles, load_config, resolve_settings, -}; +use crate::config::{expand_home, handle_init_config, handle_list_profiles}; use crate::decors::{apply_big_sur_corner_effect, apply_shadow_effect}; use crate::generators::{check_for_gif, check_for_mp4, generate_gif, generate_mp4}; use crate::summary::print_recording_summary; @@ -44,7 +42,6 @@ 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; use image::FlatSamples; use image::{DynamicImage, GenericImageView}; use std::borrow::Borrow; @@ -80,25 +77,21 @@ fn main() -> Result<()> { let args = launch(); // Handle config-related commands first - if args.get_flag("init-config") { + if args.init_config { return handle_init_config(); } - if args.get_flag("list-profiles") { + if args.list_profiles { return handle_list_profiles(); } - if args.get_flag("list-windows") { + if args.list_windows { return ls_win(); } - // Load config and resolve settings - let config = load_config()?; - let profile_name = args.get_one::("profile").map(|s| s.as_str()); - let mut settings = resolve_settings(config.as_ref(), profile_name)?; - settings.apply_cli_args(&args); + let settings = resolve_profiled_settings(&args)?; let program: String = { - if args.contains_id("program") { - args.get_one::("program").unwrap().to_string() + if let Some(prog) = &args.program { + prog.to_string() } else { let default = DEFAULT_SHELL.to_owned(); env::var("SHELL").unwrap_or(default) @@ -320,12 +313,9 @@ fn validate_wallpaper_config( /// or by the env var 'TERM_PROGRAM' and then asking the window manager for all visible windows /// and finding the Terminal in that list /// panics if WindowId was not was not there -fn current_win_id(args: &ArgMatches) -> Result<(WindowId, Option)> { - match args - .get_one::("win-id") - .ok_or_else(|| env::var("WINDOWID")) - { - Ok(win_id) => Ok((*win_id, None)), +fn current_win_id(args: &CliArgs) -> Result<(WindowId, Option)> { + match args.win_id.ok_or_else(|| env::var("WINDOWID")) { + Ok(win_id) => Ok((win_id, None)), Err(_) => { let terminal = env::var("TERM_PROGRAM").context( "Env variable 'TERM_PROGRAM' was empty but is needed for figure out the WindowId. Please set it to e.g. TERM_PROGRAM=alacitty", diff --git a/src/tips.rs b/src/tips.rs index a14c3db..b6b31f5 100644 --- a/src/tips.rs +++ b/src/tips.rs @@ -12,7 +12,7 @@ const TIPS: &[&str] = &[ "For a beautiful macOS-style background, try `--wallpaper ventura`", "Use your own wallpaper with `-p /path/to/image.png` (supports PNG, JPEG, TGA)", "Adjust wallpaper padding with `--wallpaper-padding 100` (default: 60px)", - "Save your favorite settings with `t-rec --init-config` and edit ~/.config/t-rec/config.toml", + "Save your favorite settings with `t-rec --init-config` and edit `~/.config/t-rec/config.toml` or `~/Library/Application Support/t-rec/config.toml`", "Create named profiles in your config file and use them with `--profile demo`", "List available profiles from your config with `t-rec --list-profiles`", "For smoother typing animations, try `--fps 10` or `--fps 15`",