| 1 | use std::{ |
| 2 | collections::BTreeMap, |
| 3 | env, |
| 4 | error::Error, |
| 5 | fs::{self, OpenOptions}, |
| 6 | io::{self, Write}, |
| 7 | path::PathBuf, |
| 8 | }; |
| 9 | |
| 10 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | |
| 13 | use crate::AppResult; |
| 14 | |
| 15 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] |
| 16 | #[serde(default)] |
| 17 | pub struct Config { |
| 18 | pub mpd: MpdConfig, |
| 19 | pub playback: PlaybackConfig, |
| 20 | pub keybindings: Keybindings, |
| 21 | } |
| 22 | |
| 23 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 24 | #[serde(default)] |
| 25 | pub struct MpdConfig { |
| 26 | pub host: String, |
| 27 | pub port: u16, |
| 28 | pub password: String, |
| 29 | } |
| 30 | |
| 31 | impl Default for MpdConfig { |
| 32 | fn default() -> Self { |
| 33 | Self { |
| 34 | host: "127.0.0.1".to_string(), |
| 35 | port: 6600, |
| 36 | password: String::new(), |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 42 | #[serde(default)] |
| 43 | pub struct PlaybackConfig { |
| 44 | pub seek_seconds: u64, |
| 45 | pub volume_step: u8, |
| 46 | } |
| 47 | |
| 48 | impl Default for PlaybackConfig { |
| 49 | fn default() -> Self { |
| 50 | Self { |
| 51 | seek_seconds: 2, |
| 52 | volume_step: 2, |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 58 | #[serde(default)] |
| 59 | pub struct Keybindings { |
| 60 | #[serde(flatten)] |
| 61 | bindings: BTreeMap<String, Vec<String>>, |
| 62 | } |
| 63 | |
| 64 | impl Default for Keybindings { |
| 65 | fn default() -> Self { |
| 66 | let defaults: &[(&str, &[&str])] = &[ |
| 67 | ("quit", &["q", "Ctrl+c"]), |
| 68 | ("clear_queue", &["c", "C"]), |
| 69 | ("toggle_pause", &["p"]), |
| 70 | ("pause", &["s"]), |
| 71 | ("toggle_repeat", &["r"]), |
| 72 | ("toggle_random", &["z"]), |
| 73 | ("open_playlist_menu", &["a"]), |
| 74 | ("seek_forward", &["f"]), |
| 75 | ("seek_backward", &["b"]), |
| 76 | ("cycle_tabs", &["Tab"]), |
| 77 | ("select_queue", &["1"]), |
| 78 | ("select_browser", &["2"]), |
| 79 | ("select_playlists", &["3"]), |
| 80 | ("next_track", &[">"]), |
| 81 | ("previous_track", &["<"]), |
| 82 | ("volume_up", &["=", "+"]), |
| 83 | ("volume_down", &["-"]), |
| 84 | ("toggle_mute", &["m"]), |
| 85 | ("update_database", &["U"]), |
| 86 | ("search", &["/"]), |
| 87 | ("toggle_selected_item", &["Space"]), |
| 88 | ("search_next", &["n"]), |
| 89 | ("search_previous", &["N"]), |
| 90 | ("move_down", &["j", "Down"]), |
| 91 | ("move_up", &["k", "Up"]), |
| 92 | ("activate", &["Enter", "l", "Right"]), |
| 93 | ("go_back", &["h", "Left"]), |
| 94 | ("go_to_top", &["g"]), |
| 95 | ("go_to_bottom", &["G"]), |
| 96 | ("queue_delete", &["d"]), |
| 97 | ("queue_move_down", &["J"]), |
| 98 | ("queue_move_up", &["K"]), |
| 99 | ("playlist_rename", &["R"]), |
| 100 | ("playlist_focus_contents", &["Right", "l"]), |
| 101 | ("playlist_focus_list", &["Left", "h"]), |
| 102 | ("playlist_delete_song", &["d"]), |
| 103 | ("playlist_move_song_down", &["J"]), |
| 104 | ("playlist_move_song_up", &["K"]), |
| 105 | ("playlist_delete", &["D"]), |
| 106 | ("playlist_load", &["Space", "Enter"]), |
| 107 | ("popup_close", &["q", "Esc"]), |
| 108 | ("popup_select", &["Enter"]), |
| 109 | ("input_cancel", &["Esc"]), |
| 110 | ("input_backspace", &["Backspace"]), |
| 111 | ("input_cursor_left", &["Left"]), |
| 112 | ("input_cursor_right", &["Right"]), |
| 113 | ("input_submit", &["Enter"]), |
| 114 | ]; |
| 115 | Self { |
| 116 | bindings: defaults |
| 117 | .iter() |
| 118 | .map(|(action, keys)| { |
| 119 | ( |
| 120 | (*action).to_string(), |
| 121 | keys.iter().map(|key| (*key).to_string()).collect(), |
| 122 | ) |
| 123 | }) |
| 124 | .collect(), |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | impl Keybindings { |
| 130 | pub fn matches(&self, action: &str, key: KeyEvent) -> bool { |
| 131 | self.bindings.get(action).is_some_and(|keys| { |
| 132 | keys.iter() |
| 133 | .any(|spec| parse_key_spec(spec).is_ok_and(|binding| key_matches(binding, key))) |
| 134 | }) |
| 135 | } |
| 136 | |
| 137 | fn validate(&self) -> AppResult<()> { |
| 138 | let defaults = Self::default(); |
| 139 | for (action, keys) in &self.bindings { |
| 140 | if !defaults.bindings.contains_key(action) { |
| 141 | return Err(invalid_config(&format!( |
| 142 | "keybindings contains unknown action `{action}`" |
| 143 | ))); |
| 144 | } |
| 145 | for key in keys { |
| 146 | parse_key_spec(key).map_err(|error| { |
| 147 | invalid_config(&format!( |
| 148 | "invalid keybinding `{key}` for `{action}`: {error}" |
| 149 | )) |
| 150 | })?; |
| 151 | } |
| 152 | } |
| 153 | Ok(()) |
| 154 | } |
| 155 | |
| 156 | fn apply_missing_defaults(&mut self) { |
| 157 | let defaults = Self::default().bindings; |
| 158 | for (action, keys) in &defaults { |
| 159 | if self |
| 160 | .bindings |
| 161 | .get(action) |
| 162 | .is_some_and(|current| current == &vec!["Right".to_string()]) |
| 163 | && action == "playlist_focus_contents" |
| 164 | { |
| 165 | self.bindings.insert(action.clone(), keys.clone()); |
| 166 | } |
| 167 | if self |
| 168 | .bindings |
| 169 | .get(action) |
| 170 | .is_some_and(|current| current == &vec!["Left".to_string()]) |
| 171 | && action == "playlist_focus_list" |
| 172 | { |
| 173 | self.bindings.insert(action.clone(), keys.clone()); |
| 174 | } |
| 175 | } |
| 176 | for (action, keys) in defaults { |
| 177 | self.bindings.entry(action).or_insert(keys); |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | impl Config { |
| 183 | pub fn load_or_create() -> AppResult<(Self, PathBuf)> { |
| 184 | let path = config_path()?; |
| 185 | let config = Self::load_or_create_at(&path)?; |
| 186 | Ok((config, path)) |
| 187 | } |
| 188 | |
| 189 | fn load_or_create_at(path: &std::path::Path) -> AppResult<Self> { |
| 190 | if let Some(parent) = path.parent() { |
| 191 | fs::create_dir_all(parent)?; |
| 192 | } |
| 193 | |
| 194 | let contents = match fs::read_to_string(path) { |
| 195 | Ok(contents) => contents, |
| 196 | Err(error) if error.kind() == io::ErrorKind::NotFound => { |
| 197 | let default_contents = default_config_text()?; |
| 198 | match create_config_file(path, default_contents.as_bytes()) { |
| 199 | Ok(()) => default_contents, |
| 200 | Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { |
| 201 | fs::read_to_string(path)? |
| 202 | } |
| 203 | Err(error) => return Err(Box::new(error)), |
| 204 | } |
| 205 | } |
| 206 | Err(error) => return Err(Box::new(error)), |
| 207 | }; |
| 208 | |
| 209 | let mut config: Self = toml::from_str(&contents)?; |
| 210 | config.keybindings.apply_missing_defaults(); |
| 211 | config.validate()?; |
| 212 | Ok(config) |
| 213 | } |
| 214 | |
| 215 | fn validate(&self) -> AppResult<()> { |
| 216 | if self.mpd.host.trim().is_empty() { |
| 217 | return Err(invalid_config("mpd.host cannot be empty")); |
| 218 | } |
| 219 | if self.mpd.port == 0 { |
| 220 | return Err(invalid_config("mpd.port must be between 1 and 65535")); |
| 221 | } |
| 222 | if self.playback.seek_seconds == 0 { |
| 223 | return Err(invalid_config( |
| 224 | "playback.seek_seconds must be greater than zero", |
| 225 | )); |
| 226 | } |
| 227 | if !(1..=100).contains(&self.playback.volume_step) { |
| 228 | return Err(invalid_config( |
| 229 | "playback.volume_step must be between 1 and 100", |
| 230 | )); |
| 231 | } |
| 232 | self.keybindings.validate() |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | fn parse_key_spec(spec: &str) -> Result<KeyEvent, String> { |
| 237 | if spec == "+" { |
| 238 | return Ok(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty())); |
| 239 | } |
| 240 | let mut modifiers = KeyModifiers::empty(); |
| 241 | let mut base = spec; |
| 242 | while !base.starts_with("Char(") { |
| 243 | let Some((modifier, rest)) = base.split_once('+') else { |
| 244 | break; |
| 245 | }; |
| 246 | match modifier.to_ascii_lowercase().as_str() { |
| 247 | "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, |
| 248 | "alt" => modifiers |= KeyModifiers::ALT, |
| 249 | "shift" => modifiers |= KeyModifiers::SHIFT, |
| 250 | _ => return Err(format!("unknown modifier `{modifier}`")), |
| 251 | } |
| 252 | base = rest; |
| 253 | } |
| 254 | if base.is_empty() { |
| 255 | return Err("missing key".into()); |
| 256 | } |
| 257 | let lowered_base = base.to_ascii_lowercase(); |
| 258 | let code = if let Some(number) = lowered_base |
| 259 | .strip_prefix('f') |
| 260 | .and_then(|number| number.parse::<u8>().ok()) |
| 261 | .filter(|number| (1..=24).contains(number)) |
| 262 | { |
| 263 | KeyCode::F(number) |
| 264 | } else { |
| 265 | match lowered_base.as_str() { |
| 266 | "esc" | "escape" => KeyCode::Esc, |
| 267 | "enter" | "return" => KeyCode::Enter, |
| 268 | "tab" => KeyCode::Tab, |
| 269 | "backtab" => KeyCode::BackTab, |
| 270 | "backspace" => KeyCode::Backspace, |
| 271 | "delete" | "del" => KeyCode::Delete, |
| 272 | "insert" => KeyCode::Insert, |
| 273 | "up" => KeyCode::Up, |
| 274 | "down" => KeyCode::Down, |
| 275 | "left" => KeyCode::Left, |
| 276 | "right" => KeyCode::Right, |
| 277 | "home" => KeyCode::Home, |
| 278 | "end" => KeyCode::End, |
| 279 | "pageup" => KeyCode::PageUp, |
| 280 | "pagedown" => KeyCode::PageDown, |
| 281 | "space" => KeyCode::Char(' '), |
| 282 | _ if lowered_base.starts_with("char(") && base.ends_with(')') => { |
| 283 | let inner = &base[5..base.len() - 1]; |
| 284 | let mut chars = inner.chars(); |
| 285 | let character = chars |
| 286 | .next() |
| 287 | .ok_or_else(|| "empty Char(...) key".to_string())?; |
| 288 | if chars.next().is_some() { |
| 289 | return Err("Char(...) must contain exactly one character".into()); |
| 290 | } |
| 291 | KeyCode::Char(character) |
| 292 | } |
| 293 | _ => { |
| 294 | let mut chars = base.chars(); |
| 295 | let character = chars.next().ok_or_else(|| "missing key".to_string())?; |
| 296 | if chars.next().is_some() { |
| 297 | return Err(format!("unknown key `{base}`")); |
| 298 | } |
| 299 | KeyCode::Char(character) |
| 300 | } |
| 301 | } |
| 302 | }; |
| 303 | Ok(KeyEvent::new(code, modifiers)) |
| 304 | } |
| 305 | |
| 306 | fn key_matches(binding: KeyEvent, event: KeyEvent) -> bool { |
| 307 | if binding.code != event.code { |
| 308 | return false; |
| 309 | } |
| 310 | let mut event_modifiers = event.modifiers; |
| 311 | if !binding.modifiers.contains(KeyModifiers::SHIFT) { |
| 312 | event_modifiers.remove(KeyModifiers::SHIFT); |
| 313 | } |
| 314 | binding.modifiers == event_modifiers |
| 315 | } |
| 316 | |
| 317 | pub fn config_path() -> io::Result<PathBuf> { |
| 318 | let base = env::var_os("CONFIG_HOME") |
| 319 | .or_else(|| env::var_os("XDG_CONFIG_HOME")) |
| 320 | .map(PathBuf::from) |
| 321 | .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) |
| 322 | .ok_or_else(|| { |
| 323 | io::Error::new( |
| 324 | io::ErrorKind::NotFound, |
| 325 | "CONFIG_HOME, XDG_CONFIG_HOME, and HOME are all unset", |
| 326 | ) |
| 327 | })?; |
| 328 | if base.as_os_str().is_empty() { |
| 329 | return Err(io::Error::new( |
| 330 | io::ErrorKind::InvalidInput, |
| 331 | "configuration home environment variable cannot be empty", |
| 332 | )); |
| 333 | } |
| 334 | Ok(base.join("rmptui").join("config.toml")) |
| 335 | } |
| 336 | |
| 337 | fn default_config_text() -> AppResult<String> { |
| 338 | let serialized = toml::to_string_pretty(&Config::default())?; |
| 339 | let mut documented = String::from( |
| 340 | "# rmptui configuration. Values below are the built-in defaults.\n\ |
| 341 | # Save changes and restart rmptui for them to take effect.\n\ |
| 342 | #\n\ |
| 343 | # Config file location: $CONFIG_HOME/rmptui/config.toml, falling back to\n\ |
| 344 | # $XDG_CONFIG_HOME/rmptui/config.toml, then $HOME/.config/rmptui/config.toml.\n\ |
| 345 | #\n\ |
| 346 | # Environment overrides:\n\ |
| 347 | # MPD_HOST overrides [mpd].host. It may include a port (host:port),\n\ |
| 348 | # or credentials as password@host. A port embedded in MPD_HOST wins\n\ |
| 349 | # over MPD_PORT.\n\ |
| 350 | # MPD_PORT overrides [mpd].port when MPD_HOST has no embedded port.\n\ |
| 351 | #\n\ |
| 352 | # Key names may be characters or named keys (Enter, Esc, Tab, Space,\n\ |
| 353 | # arrows, Backspace, F1-F24). Prefix modifiers with Ctrl+, Alt+, or\n\ |
| 354 | # Shift+. Use arrays for aliases and [] to disable an action.\n\ |
| 355 | # The password is stored as plain text; this file is created with\n\ |
| 356 | # owner-only permissions on Unix.\n\n", |
| 357 | ); |
| 358 | |
| 359 | let mut section = ""; |
| 360 | for line in serialized.lines() { |
| 361 | if line.starts_with('[') && line.ends_with(']') { |
| 362 | section = &line[1..line.len() - 1]; |
| 363 | let description = match section { |
| 364 | "mpd" => "# MPD server connection settings.", |
| 365 | "playback" => "# Playback adjustment amounts.", |
| 366 | "keybindings" => "# Action keys, matched only in relevant UI contexts.", |
| 367 | _ => "# Configuration options.", |
| 368 | }; |
| 369 | documented.push_str(description); |
| 370 | documented.push('\n'); |
| 371 | documented.push_str(line); |
| 372 | documented.push('\n'); |
| 373 | continue; |
| 374 | } |
| 375 | |
| 376 | if let Some((key, _)) = line.split_once(" = ") { |
| 377 | let description = option_description(section, key); |
| 378 | documented.push_str("# "); |
| 379 | documented.push_str(description); |
| 380 | documented.push('\n'); |
| 381 | } |
| 382 | documented.push_str(line); |
| 383 | documented.push('\n'); |
| 384 | } |
| 385 | |
| 386 | Ok(documented) |
| 387 | } |
| 388 | |
| 389 | fn option_description(section: &str, key: &str) -> &'static str { |
| 390 | match (section, key) { |
| 391 | ("mpd", "host") => "MPD hostname or address. MPD_HOST overrides this value.", |
| 392 | ("mpd", "port") => "MPD TCP port. MPD_PORT overrides it unless MPD_HOST includes a port.", |
| 393 | ("mpd", "password") => { |
| 394 | "Optional MPD password; leave empty when the server has no password." |
| 395 | } |
| 396 | ("playback", "seek_seconds") => "Seconds moved by seek_forward and seek_backward.", |
| 397 | ("playback", "volume_step") => { |
| 398 | "Volume percentage changed by volume_up and volume_down (1-100)." |
| 399 | } |
| 400 | ("keybindings", "quit") => "Exit rmptui.", |
| 401 | ("keybindings", "clear_queue") => "Remove every song from the current play queue.", |
| 402 | ("keybindings", "toggle_pause") => "Toggle playback between paused and playing.", |
| 403 | ("keybindings", "pause") => "Pause playback.", |
| 404 | ("keybindings", "toggle_repeat") => "Toggle repeat mode.", |
| 405 | ("keybindings", "toggle_random") => "Toggle random playback mode.", |
| 406 | ("keybindings", "open_playlist_menu") => { |
| 407 | "Open the menu for adding the selection to a playlist." |
| 408 | } |
| 409 | ("keybindings", "seek_forward") => "Seek forward by playback.seek_seconds.", |
| 410 | ("keybindings", "seek_backward") => "Seek backward by playback.seek_seconds.", |
| 411 | ("keybindings", "cycle_tabs") => "Cycle through Queue, Browser, and Playlists views.", |
| 412 | ("keybindings", "select_queue") => "Open the Queue view.", |
| 413 | ("keybindings", "select_browser") => "Open the Browser view.", |
| 414 | ("keybindings", "select_playlists") => "Open the Playlists view.", |
| 415 | ("keybindings", "next_track") => "Start the next track in the queue.", |
| 416 | ("keybindings", "previous_track") => "Start the previous track in the queue.", |
| 417 | ("keybindings", "volume_up") => "Increase volume by playback.volume_step.", |
| 418 | ("keybindings", "volume_down") => "Decrease volume by playback.volume_step.", |
| 419 | ("keybindings", "toggle_mute") => "Toggle mute.", |
| 420 | ("keybindings", "update_database") => "Ask MPD to rescan its music database.", |
| 421 | ("keybindings", "search") => "Start fuzzy search in the current list.", |
| 422 | ("keybindings", "toggle_selected_item") => { |
| 423 | "Toggle the selected song in the queue or browser." |
| 424 | } |
| 425 | ("keybindings", "search_next") => "Select the next browser fuzzy-search match.", |
| 426 | ("keybindings", "search_previous") => "Select the previous browser fuzzy-search match.", |
| 427 | ("keybindings", "move_down") => "Move selection down in the focused list.", |
| 428 | ("keybindings", "move_up") => "Move selection up in the focused list.", |
| 429 | ("keybindings", "activate") => { |
| 430 | "Activate the focused item (for example, play a song or enter a directory)." |
| 431 | } |
| 432 | ("keybindings", "go_back") => "Go to the parent directory in the Browser view.", |
| 433 | ("keybindings", "go_to_top") => "Select the first item in the focused list.", |
| 434 | ("keybindings", "go_to_bottom") => "Select the last item in the focused list.", |
| 435 | ("keybindings", "queue_delete") => "Remove the selected song from the current queue.", |
| 436 | ("keybindings", "queue_move_down") => "Move the selected queue song down one position.", |
| 437 | ("keybindings", "queue_move_up") => "Move the selected queue song up one position.", |
| 438 | ("keybindings", "playlist_rename") => "Rename the selected saved playlist.", |
| 439 | ("keybindings", "playlist_focus_contents") => { |
| 440 | "Focus the selected playlist's song contents." |
| 441 | } |
| 442 | ("keybindings", "playlist_focus_list") => "Return focus to the saved-playlist list.", |
| 443 | ("keybindings", "playlist_delete_song") => { |
| 444 | "Delete the selected song from the saved playlist." |
| 445 | } |
| 446 | ("keybindings", "playlist_move_song_down") => { |
| 447 | "Move the selected saved-playlist song down one position." |
| 448 | } |
| 449 | ("keybindings", "playlist_move_song_up") => { |
| 450 | "Move the selected saved-playlist song up one position." |
| 451 | } |
| 452 | ("keybindings", "playlist_delete") => "Delete the selected saved playlist.", |
| 453 | ("keybindings", "playlist_load") => { |
| 454 | "Append the selected saved playlist to the queue and start playback." |
| 455 | } |
| 456 | ("keybindings", "popup_close") => "Close the add-to-playlist menu.", |
| 457 | ("keybindings", "popup_select") => { |
| 458 | "Choose the selected destination in the add-to-playlist menu." |
| 459 | } |
| 460 | ("keybindings", "input_cancel") => "Cancel the active search or playlist-name input.", |
| 461 | ("keybindings", "input_backspace") => "Delete the character before the input cursor.", |
| 462 | ("keybindings", "input_cursor_left") => "Move the input cursor left.", |
| 463 | ("keybindings", "input_cursor_right") => "Move the input cursor right.", |
| 464 | ("keybindings", "input_submit") => "Submit the active search or playlist-name input.", |
| 465 | _ => "Key aliases for this action.", |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | fn create_config_file(path: &std::path::Path, contents: &[u8]) -> io::Result<()> { |
| 470 | let mut options = OpenOptions::new(); |
| 471 | options.write(true).create_new(true); |
| 472 | #[cfg(unix)] |
| 473 | { |
| 474 | use std::os::unix::fs::OpenOptionsExt; |
| 475 | options.mode(0o600); |
| 476 | } |
| 477 | let mut file = options.open(path)?; |
| 478 | file.write_all(contents) |
| 479 | } |
| 480 | |
| 481 | fn invalid_config(message: &str) -> Box<dyn Error + Send + Sync> { |
| 482 | Box::new(io::Error::new( |
| 483 | io::ErrorKind::InvalidData, |
| 484 | message.to_string(), |
| 485 | )) |
| 486 | } |
| 487 | |
| 488 | #[cfg(test)] |
| 489 | mod tests { |
| 490 | use super::*; |
| 491 | |
| 492 | #[test] |
| 493 | fn default_config_round_trips_with_all_sections() { |
| 494 | let config = Config::default(); |
| 495 | let serialized = toml::to_string_pretty(&config).unwrap(); |
| 496 | let parsed: Config = toml::from_str(&serialized).unwrap(); |
| 497 | |
| 498 | assert_eq!(parsed.mpd.host, "127.0.0.1"); |
| 499 | assert_eq!(parsed.mpd.port, 6600); |
| 500 | assert_eq!(parsed.playback.seek_seconds, 2); |
| 501 | assert_eq!(parsed.playback.volume_step, 2); |
| 502 | assert!(parsed.keybindings.matches( |
| 503 | "quit", |
| 504 | KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()) |
| 505 | )); |
| 506 | assert!(parsed.keybindings.matches( |
| 507 | "quit", |
| 508 | KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) |
| 509 | )); |
| 510 | } |
| 511 | |
| 512 | #[test] |
| 513 | fn generated_default_config_is_toml_and_has_helpful_comments() { |
| 514 | let contents = default_config_text().unwrap(); |
| 515 | let parsed: Config = toml::from_str(&contents).unwrap(); |
| 516 | assert_eq!(parsed.mpd.host, "127.0.0.1"); |
| 517 | assert!(contents.starts_with("# rmptui configuration.")); |
| 518 | assert!(contents.contains("password = \"\"")); |
| 519 | assert!(contents.contains("[keybindings]")); |
| 520 | assert!(contents.contains("input_submit = [\"Enter\"]")); |
| 521 | assert!(contents.contains("MPD_HOST overrides [mpd].host")); |
| 522 | assert!(contents.contains("MPD_PORT overrides")); |
| 523 | assert!(contents.contains("# Focus the selected playlist's song contents.")); |
| 524 | assert!(contents |
| 525 | .contains("# Append the selected saved playlist to the queue and start playback.")); |
| 526 | assert!(contents.contains("playlist_focus_contents = [")); |
| 527 | for action in Keybindings::default().bindings.keys() { |
| 528 | assert_ne!( |
| 529 | option_description("keybindings", action), |
| 530 | "Key aliases for this action.", |
| 531 | "missing generated-config documentation for `{action}`" |
| 532 | ); |
| 533 | } |
| 534 | } |
| 535 | |
| 536 | #[test] |
| 537 | fn creates_default_config_and_reads_user_changes() { |
| 538 | let unique = format!( |
| 539 | "rmptui-config-test-{}-{}", |
| 540 | std::process::id(), |
| 541 | std::time::SystemTime::now() |
| 542 | .duration_since(std::time::UNIX_EPOCH) |
| 543 | .unwrap() |
| 544 | .as_nanos() |
| 545 | ); |
| 546 | let directory = env::temp_dir().join(unique); |
| 547 | let path = directory.join("rmptui").join("config.toml"); |
| 548 | |
| 549 | let created = Config::load_or_create_at(&path).unwrap(); |
| 550 | assert_eq!(created.playback.volume_step, 2); |
| 551 | let contents = fs::read_to_string(&path).unwrap(); |
| 552 | assert!(contents.contains("password = \"\"")); |
| 553 | |
| 554 | fs::write( |
| 555 | &path, |
| 556 | contents.replace("volume_step = 2", "volume_step = 8"), |
| 557 | ) |
| 558 | .unwrap(); |
| 559 | let loaded = Config::load_or_create_at(&path).unwrap(); |
| 560 | assert_eq!(loaded.playback.volume_step, 8); |
| 561 | |
| 562 | fs::remove_dir_all(directory).unwrap(); |
| 563 | } |
| 564 | |
| 565 | #[test] |
| 566 | fn partial_configuration_uses_defaults_for_missing_values() { |
| 567 | let parsed: Config = toml::from_str( |
| 568 | r#" |
| 569 | [mpd] |
| 570 | host = "music.local" |
| 571 | port = 6601 |
| 572 | "#, |
| 573 | ) |
| 574 | .unwrap(); |
| 575 | |
| 576 | assert_eq!(parsed.mpd.host, "music.local"); |
| 577 | assert_eq!(parsed.mpd.port, 6601); |
| 578 | assert_eq!(parsed.playback.seek_seconds, 2); |
| 579 | assert_eq!( |
| 580 | parsed.keybindings.bindings.len(), |
| 581 | Keybindings::default().bindings.len() |
| 582 | ); |
| 583 | } |
| 584 | |
| 585 | #[test] |
| 586 | fn keybinding_parser_supports_aliases_modifiers_and_punctuation() { |
| 587 | let bindings = Keybindings::default(); |
| 588 | assert!(bindings.matches( |
| 589 | "volume_up", |
| 590 | KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty()) |
| 591 | )); |
| 592 | assert!(bindings.matches( |
| 593 | "move_down", |
| 594 | KeyEvent::new(KeyCode::Down, KeyModifiers::empty()) |
| 595 | )); |
| 596 | assert!(bindings.matches( |
| 597 | "quit", |
| 598 | KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) |
| 599 | )); |
| 600 | assert!(bindings.matches( |
| 601 | "search_previous", |
| 602 | KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT) |
| 603 | )); |
| 604 | assert!(parse_key_spec("Ctrl+Char(+)").is_ok()); |
| 605 | assert_eq!(parse_key_spec("F12").unwrap().code, KeyCode::F(12)); |
| 606 | } |
| 607 | |
| 608 | #[test] |
| 609 | fn partial_keybinding_configuration_keeps_other_defaults() { |
| 610 | let mut parsed: Config = toml::from_str( |
| 611 | r#" |
| 612 | [keybindings] |
| 613 | quit = ["x"] |
| 614 | "#, |
| 615 | ) |
| 616 | .unwrap(); |
| 617 | parsed.keybindings.apply_missing_defaults(); |
| 618 | |
| 619 | assert!(parsed.keybindings.matches( |
| 620 | "quit", |
| 621 | KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()) |
| 622 | )); |
| 623 | assert!(parsed.keybindings.matches( |
| 624 | "move_down", |
| 625 | KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()) |
| 626 | )); |
| 627 | } |
| 628 | |
| 629 | #[test] |
| 630 | fn playlist_focus_defaults_migrate_with_existing_default_config() { |
| 631 | let mut parsed: Config = toml::from_str( |
| 632 | r#" |
| 633 | [keybindings] |
| 634 | playlist_focus_contents = ["Right"] |
| 635 | playlist_focus_list = ["Left"] |
| 636 | "#, |
| 637 | ) |
| 638 | .unwrap(); |
| 639 | parsed.keybindings.apply_missing_defaults(); |
| 640 | |
| 641 | assert_eq!( |
| 642 | parsed.keybindings.bindings["playlist_focus_contents"], |
| 643 | vec!["Right", "l"] |
| 644 | ); |
| 645 | assert_eq!( |
| 646 | parsed.keybindings.bindings["playlist_focus_list"], |
| 647 | vec!["Left", "h"] |
| 648 | ); |
| 649 | assert_eq!( |
| 650 | parsed.keybindings.bindings["playlist_load"], |
| 651 | vec!["Space", "Enter"] |
| 652 | ); |
| 653 | } |
| 654 | |
| 655 | #[test] |
| 656 | fn invalid_keybinding_actions_and_keys_are_rejected() { |
| 657 | let mut bindings = Keybindings::default(); |
| 658 | bindings |
| 659 | .bindings |
| 660 | .insert("unknown_action".to_string(), vec!["x".to_string()]); |
| 661 | assert!(bindings.validate().is_err()); |
| 662 | |
| 663 | let mut bindings = Keybindings::default(); |
| 664 | bindings |
| 665 | .bindings |
| 666 | .insert("quit".to_string(), vec!["not-a-key".to_string()]); |
| 667 | assert!(bindings.validate().is_err()); |
| 668 | } |
| 669 | } |