diff --git a/Cargo.lock b/Cargo.lock index 390b353..1d09561 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -216,6 +216,27 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "either" version = "1.15.0" @@ -245,6 +266,12 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -252,7 +279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -317,6 +344,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -329,6 +367,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "humantime" version = "2.3.0" @@ -350,6 +394,16 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -392,6 +446,16 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -489,6 +553,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "png" version = "0.18.0" @@ -570,6 +640,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.2" @@ -609,7 +690,17 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", ] [[package]] @@ -632,6 +723,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -673,6 +773,7 @@ dependencies = [ "core-foundation", "core-foundation-sys", "core-graphics", + "dirs", "env_logger", "humantime", "image", @@ -680,8 +781,10 @@ dependencies = [ "objc-foundation", "objc_id", "rayon", + "serde", "simplerand", "tempfile", + "toml", "x11rb", ] @@ -692,12 +795,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -710,6 +874,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -725,6 +895,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -734,6 +913,72 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +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", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 2eb5f78..a8eb1cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ log = "0.4" env_logger = "0.11" humantime = "2.3" simplerand = "1.6" +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +dirs = "5.0" [dependencies.clap] version = "4.5" diff --git a/README.md b/README.md index 863b70f..c609627 100644 --- a/README.md +++ b/README.md @@ -168,10 +168,100 @@ Options: -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] + -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 ``` +### Configuration File + +You can save your preferred settings in a config file to avoid typing them every time. + +**Quick start:** + +```sh +# Create a starter config file +t-rec --init-config + +# List available profiles +t-rec --list-profiles + +# Use a profile +t-rec --profile demo +``` + +**Config file locations** (searched in order): +1. `./t-rec.toml` (project-local) +2. `~/.config/t-rec/config.toml` (Linux/macOS) +3. `%APPDATA%\t-rec\config.toml` (Windows) + +**Example config file:** + +```toml +# Default settings applied to all recordings +[default] +wallpaper = "ventura" +wallpaper-padding = 80 + +# Named profiles for different use cases +[profiles.demo] +wallpaper = "ventura" +wallpaper-padding = 120 +start-pause = "10s" +idle-pause = "5s" + +[profiles.quick] +quiet = true +idle-pause = "1s" + +# Custom wallpaper with $HOME expansion +[profiles.custom] +wallpaper = "$HOME/Pictures/my-wallpaper.png" +wallpaper-padding = 80 +``` + +**Using profiles:** + +```sh +# Use default settings from config +t-rec + +# Use a specific profile +t-rec --profile demo + +# Override a profile setting +t-rec --profile demo --wallpaper-padding 150 + +# List available profiles +t-rec --list-profiles +``` + +**Available config options:** + +| Option | Type | Description | +|--------|------|-------------| +| `verbose` | bool | Enable verbose output | +| `quiet` | bool | Suppress the Ctrl+D banner | +| `video` | bool | Also generate mp4 video | +| `video-only` | bool | Only generate mp4, no gif | +| `decor` | string | Border decoration (`shadow`, `none`) | +| `wallpaper` | string | Wallpaper preset or file path (supports `$HOME`) | +| `wallpaper-padding` | number | Padding around recording (1-500) | +| `bg` | string | Background color (`white`, `black`, `transparent`) | +| `natural` | bool | Disable idle detection | +| `start-pause` | string | Pause at start (e.g., `2s`, `500ms`) | +| `end-pause` | string | Pause at end | +| `idle-pause` | string | Max idle time before optimization | +| `output` | string | Output filename (without extension) | + +**Note:** CLI arguments always override config file settings. + ### 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/src/cli.rs b/src/cli.rs index 11b8102..d7d8e54 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -144,5 +144,25 @@ pub fn launch() -> ArgMatches { .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'"), - ).get_matches() + ) + .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() } diff --git a/src/config/commands.rs b/src/config/commands.rs new file mode 100644 index 0000000..bd5cdf7 --- /dev/null +++ b/src/config/commands.rs @@ -0,0 +1,61 @@ +use anyhow::{Context, Result}; +use std::fs; + +use super::file::load_config; +use super::init::STARTER_CONFIG; + +/// Handle --init-config command +pub fn handle_init_config() -> Result<()> { + let config_dir = dirs::config_dir() + .context("Cannot determine config directory")? + .join("t-rec"); + + let config_path = config_dir.join("config.toml"); + + if config_path.exists() { + println!("Config file already exists: {}", config_path.display()); + return Ok(()); + } + + // Create directory if needed + fs::create_dir_all(&config_dir).with_context(|| { + format!( + "Failed to create config directory: {}", + config_dir.display() + ) + })?; + + fs::write(&config_path, STARTER_CONFIG) + .with_context(|| format!("Failed to write config file: {}", config_path.display()))?; + + println!("Created config file: {}", config_path.display()); + println!("Edit it to customize your t-rec settings."); + + Ok(()) +} + +/// Handle --list-profiles command +pub fn handle_list_profiles() -> Result<()> { + match load_config()? { + Some(config) => { + if config.profiles.is_empty() { + println!("No profiles defined in config file."); + println!("Add profiles to your config file like this:"); + println!(); + println!("[profiles.demo]"); + println!("wallpaper = \"ventura\""); + println!("wallpaper-padding = 100"); + } else { + println!("Available profiles:"); + for name in config.profiles.keys() { + println!(" - {}", name); + } + } + } + None => { + println!("No config file found."); + println!("Run `t-rec --init-config` to create one."); + } + } + Ok(()) +} diff --git a/src/config/file.rs b/src/config/file.rs new file mode 100644 index 0000000..cf3f101 --- /dev/null +++ b/src/config/file.rs @@ -0,0 +1,96 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use super::ProfileSettings; + +/// Raw config file structure (matches TOML layout) +#[derive(Debug, Deserialize, Default)] +pub struct ConfigFile { + #[serde(default)] + pub default: ProfileSettings, + #[serde(default)] + pub profiles: HashMap, +} + +/// Find the config file path +pub fn find_config_file() -> Option { + // 1. Project-local + let local = PathBuf::from("t-rec.toml"); + if local.exists() { + return Some(local); + } + + // 2. XDG config directory + if let Some(config_dir) = dirs::config_dir() { + let xdg_path = config_dir.join("t-rec").join("config.toml"); + if xdg_path.exists() { + return Some(xdg_path); + } + } + + None +} + +/// Load and parse the config file +pub fn load_config() -> Result> { + match find_config_file() { + Some(path) => { + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + let config: ConfigFile = toml::from_str(&content) + .with_context(|| format!("Failed to parse config file: {}", path.display()))?; + Ok(Some(config)) + } + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty_config() { + let config: ConfigFile = toml::from_str("").unwrap(); + assert!(config.profiles.is_empty()); + } + + #[test] + fn test_parse_config_with_default() { + let toml = r#" +[default] +wallpaper = "ventura" +wallpaper-padding = 80 +"#; + let config: ConfigFile = toml::from_str(toml).unwrap(); + assert_eq!(config.default.wallpaper, Some("ventura".to_string())); + assert_eq!(config.default.wallpaper_padding, Some(80)); + } + + #[test] + fn test_parse_config_with_profiles() { + let toml = r#" +[default] +quiet = true + +[profiles.demo] +wallpaper = "ventura" +wallpaper-padding = 100 + +[profiles.quick] +idle-pause = "1s" +"#; + let config: ConfigFile = toml::from_str(toml).unwrap(); + assert_eq!(config.default.quiet, Some(true)); + assert_eq!(config.profiles.len(), 2); + assert!(config.profiles.contains_key("demo")); + assert!(config.profiles.contains_key("quick")); + assert_eq!( + config.profiles.get("demo").unwrap().wallpaper_padding, + Some(100) + ); + } +} diff --git a/src/config/init.rs b/src/config/init.rs new file mode 100644 index 0000000..0df8049 --- /dev/null +++ b/src/config/init.rs @@ -0,0 +1,27 @@ +pub const STARTER_CONFIG: &str = r#"# t-rec configuration file +# See: https://github.com/sassman/t-rec-rs + +# Default settings applied to all recordings +[default] +# wallpaper = "ventura" +# wallpaper-padding = 60 +# start-pause = "2s" + +# Named profiles for different use cases +# Use with: t-rec --profile demo + +[profiles.demo] +wallpaper = "ventura" +wallpaper-padding = 100 +start-pause = "5s" +idle-pause = "5s" + +[profiles.quick] +quiet = true +idle-pause = "1s" + +# Example with custom wallpaper (use $HOME for home directory) +# [profiles.custom] +# wallpaper = "$HOME/Pictures/my-wallpaper.png" +# wallpaper-padding = 80 +"#; diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..4791f2f --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,8 @@ +mod commands; +mod file; +mod init; +mod profile; + +pub use commands::{handle_init_config, handle_list_profiles}; +pub use file::{load_config, ConfigFile}; +pub use profile::{expand_home, resolve_settings, ProfileSettings}; diff --git a/src/config/profile.rs b/src/config/profile.rs new file mode 100644 index 0000000..26c9e26 --- /dev/null +++ b/src/config/profile.rs @@ -0,0 +1,256 @@ +use anyhow::Result; +use clap::ArgMatches; +use serde::Deserialize; + +use super::ConfigFile; + +/// Settings that can be specified in a profile +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct ProfileSettings { + pub verbose: Option, + pub quiet: Option, + pub video: Option, + pub video_only: Option, + pub decor: Option, + pub wallpaper: Option, + pub wallpaper_padding: Option, + pub bg: Option, + pub natural: Option, + pub end_pause: Option, + pub start_pause: Option, + pub idle_pause: Option, + pub output: Option, +} + +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(); + } + } + + /// 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()); + } + } + } + + /// Get final values with defaults applied + pub fn verbose(&self) -> bool { + self.verbose.unwrap_or(false) + } + pub fn quiet(&self) -> bool { + self.quiet.unwrap_or(false) + } + pub fn video(&self) -> bool { + self.video.unwrap_or(false) + } + pub fn video_only(&self) -> bool { + self.video_only.unwrap_or(false) + } + pub fn natural(&self) -> bool { + self.natural.unwrap_or(false) + } + pub fn decor(&self) -> &str { + self.decor.as_deref().unwrap_or("none") + } + pub fn bg(&self) -> &str { + self.bg.as_deref().unwrap_or("transparent") + } + pub fn wallpaper_padding(&self) -> u32 { + self.wallpaper_padding.unwrap_or(60) + } + pub fn idle_pause(&self) -> &str { + self.idle_pause.as_deref().unwrap_or("3s") + } + pub fn output(&self) -> &str { + self.output.as_deref().unwrap_or("t-rec") + } +} + +/// Expand $HOME in a string value (only $HOME is supported) +pub fn expand_home(value: &str) -> String { + if value.contains("$HOME") { + if let Some(home) = dirs::home_dir() { + return value.replace("$HOME", &home.to_string_lossy()); + } + } + value.to_string() +} + +/// Resolve settings: default -> profile -> CLI args +pub fn resolve_settings( + config: Option<&ConfigFile>, + profile_name: Option<&str>, +) -> Result { + let mut settings = ProfileSettings::default(); + + if let Some(config) = config { + // Apply default section + settings.merge(&config.default); + + // Apply named profile if specified + if let Some(name) = profile_name { + if let Some(profile) = config.profiles.get(name) { + settings.merge(profile); + } else { + let available: Vec<_> = config.profiles.keys().cloned().collect(); + if available.is_empty() { + anyhow::bail!( + "Profile '{}' not found. No profiles defined in config.", + name + ); + } else { + anyhow::bail!( + "Profile '{}' not found. Available profiles: {}", + name, + available.join(", ") + ); + } + } + } + } + + Ok(settings) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_home() { + let home = dirs::home_dir().unwrap(); + let home_str = home.to_string_lossy(); + + assert_eq!( + expand_home("$HOME/Pictures/bg.png"), + format!("{}/Pictures/bg.png", home_str) + ); + assert_eq!(expand_home("/absolute/path.png"), "/absolute/path.png"); + assert_eq!(expand_home("relative/path.png"), "relative/path.png"); + } + + #[test] + fn test_profile_merge() { + let mut base = ProfileSettings { + wallpaper: Some("ventura".to_string()), + wallpaper_padding: Some(60), + ..Default::default() + }; + + let overlay = ProfileSettings { + wallpaper_padding: Some(100), + quiet: Some(true), + ..Default::default() + }; + + base.merge(&overlay); + + assert_eq!(base.wallpaper, Some("ventura".to_string())); + assert_eq!(base.wallpaper_padding, Some(100)); + assert_eq!(base.quiet, Some(true)); + } + + #[test] + fn test_default_values() { + let settings = ProfileSettings::default(); + + 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"); + } +} diff --git a/src/main.rs b/src/main.rs index 8936090..d158dd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod assets; mod cli; mod common; +mod config; mod decors; mod generators; mod tips; @@ -25,6 +26,9 @@ use crate::windows::*; use crate::cli::launch; 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::decors::{apply_big_sur_corner_effect, apply_shadow_effect}; use crate::generators::{check_for_gif, check_for_mp4, generate_gif, generate_mp4}; use crate::tips::show_tip; @@ -70,10 +74,24 @@ fn main() -> Result<()> { env_logger::init(); let args = launch(); + + // Handle config-related commands first + if args.get_flag("init-config") { + return handle_init_config(); + } + if args.get_flag("list-profiles") { + return handle_list_profiles(); + } if args.get_flag("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 program: String = { if args.contains_id("program") { args.get_one::("program").unwrap().to_string() @@ -87,15 +105,15 @@ fn main() -> Result<()> { api.calibrate(win_id)?; // Validate wallpaper BEFORE recording starts - let wallpaper_config = validate_wallpaper_config(&args, &api, win_id)?; + let wallpaper_config = validate_wallpaper_config(&settings, &api, win_id)?; - let force_natural = args.get_flag("natural-mode"); - let should_generate_gif = !args.get_flag("video-only"); - let should_generate_video = args.get_flag("video") || args.get_flag("video-only"); + let force_natural = settings.natural(); + let should_generate_gif = !settings.video_only(); + let should_generate_video = settings.video() || settings.video_only(); let (start_delay, end_delay, idle_pause) = ( - parse_delay(args.get_one::("start-pause"), "start-pause")?, - parse_delay(args.get_one::("end-pause"), "end-pause")?, - parse_delay(args.get_one::("idle-pause"), "idle-pause")?, + parse_delay(settings.start_pause.as_deref(), "start-pause")?, + parse_delay(settings.end_pause.as_deref(), "end-pause")?, + parse_delay(Some(settings.idle_pause()), "idle-pause")?, ); if should_generate_gif { @@ -130,7 +148,7 @@ fn main() -> Result<()> { clear_screen(); io::stdout().flush().unwrap(); - if args.get_flag("verbose") { + if settings.verbose() { println!( "Frame cache dir: {:?}", tempdir.lock().expect("Cannot lock tempdir resource").path() @@ -141,7 +159,7 @@ fn main() -> Result<()> { println!("Recording window id: {}", win_id); } } - if !args.get_flag("quiet") { + if !settings.quiet() { println!("[t-rec]: Press Ctrl+D to end recording"); } thread::sleep(Duration::from_millis(1250)); @@ -169,11 +187,11 @@ fn main() -> Result<()> { tempdir.lock().unwrap().borrow(), ); - if let Some("shadow") = args.get_one::("decor").map(|s| s.as_ref()) { + if settings.decor() == "shadow" { apply_shadow_effect( &time_codes.lock().unwrap(), tempdir.lock().unwrap().borrow(), - args.get_one::("bg").unwrap().to_string(), + settings.bg().to_string(), ); } @@ -186,7 +204,7 @@ fn main() -> Result<()> { ); } - let target = target_file(args.get_one::("file").unwrap()); + let target = target_file(settings.output()); let mut time = Duration::default(); if should_generate_gif { @@ -221,23 +239,25 @@ fn main() -> Result<()> { /// Returns `Some((wallpaper, padding))` if wallpaper is configured, `None` otherwise. /// Fails early with a clear error message if the wallpaper is invalid or too small. fn validate_wallpaper_config( - args: &ArgMatches, + settings: &config::ProfileSettings, api: &impl PlatformApi, win_id: WindowId, ) -> Result> { - let wp_value = match args.get_one::("wallpaper") { + let wp_value = match &settings.wallpaper { Some(v) => v, None => return Ok(None), }; - let padding = *args.get_one::("wallpaper-padding").unwrap(); + // Expand $HOME in wallpaper path + let wp_value = expand_home(wp_value); + let padding = settings.wallpaper_padding(); // Capture a screenshot to get terminal dimensions let screenshot = api.capture_window_screenshot(win_id)?; let terminal_width = screenshot.layout.width; let terminal_height = screenshot.layout.height; - let wallpaper = if is_builtin_wallpaper(wp_value) { + let wallpaper = if is_builtin_wallpaper(&wp_value) { match wp_value.to_lowercase().as_str() { "ventura" => { // Validate built-in wallpaper dimensions too @@ -263,7 +283,7 @@ fn validate_wallpaper_config( } } else { // Custom wallpaper path - validate before recording - let path = Path::new(wp_value); + let path = Path::new(&wp_value); load_and_validate_wallpaper(path, terminal_width, terminal_height, padding)? }; diff --git a/src/tips.rs b/src/tips.rs index ac3fe5b..772ac94 100644 --- a/src/tips.rs +++ b/src/tips.rs @@ -12,6 +12,9 @@ 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", + "Create named profiles in your config file and use them with `--profile demo`", + "List available profiles from your config with `t-rec --list-profiles`", ]; ///