pub mod keymap;
pub mod state;
pub use keymap::{Action, Keymap};
pub use state::State;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LibraryLayout {
#[default]
Columns,
Tree,
}
impl LibraryLayout {
pub fn name(self) -> &'static str {
match self {
LibraryLayout::Columns => "columns",
LibraryLayout::Tree => "tree",
}
}
pub fn from_name(name: &str) -> Self {
match name.trim().to_ascii_lowercase().as_str() {
"tree" => LibraryLayout::Tree,
_ => LibraryLayout::Columns,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SidePane {
Off,
#[default]
Queue,
Library,
}
impl SidePane {
pub fn name(self) -> &'static str {
match self {
SidePane::Off => "off",
SidePane::Queue => "queue",
SidePane::Library => "library",
}
}
pub fn from_name(name: &str) -> Self {
match name.trim().to_ascii_lowercase().as_str() {
"off" => SidePane::Off,
"library" => SidePane::Library,
_ => SidePane::Queue,
}
}
pub fn shows_anything(self) -> bool {
self != SidePane::Off
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum CoverMode {
#[default]
Auto,
Kitty,
Sixel,
Blocks,
Off,
}
impl CoverMode {
pub fn name(self) -> &'static str {
match self {
CoverMode::Auto => "auto",
CoverMode::Kitty => "kitty",
CoverMode::Sixel => "sixel",
CoverMode::Blocks => "blocks",
CoverMode::Off => "off",
}
}
pub fn uses_sixel(self, terminal_ok: bool) -> bool {
match self {
CoverMode::Sixel => true,
CoverMode::Auto => terminal_ok,
_ => false,
}
}
pub fn uses_kitty(self, terminal_ok: bool) -> bool {
match self {
CoverMode::Kitty => true,
CoverMode::Auto => terminal_ok,
_ => false,
}
}
pub fn draws_anything(self) -> bool {
!matches!(self, CoverMode::Off)
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum VisualizerMode {
#[default]
Bars,
Braille,
Off,
}
impl VisualizerMode {
pub fn cycle(self) -> Self {
match self {
VisualizerMode::Bars => VisualizerMode::Braille,
VisualizerMode::Braille => VisualizerMode::Off,
VisualizerMode::Off => VisualizerMode::Bars,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub visualizer_height: u16,
pub visualizer_bar_width: u16,
pub visualizer_mode: VisualizerMode,
pub cover_enabled: bool,
pub cover_mode: CoverMode,
pub cell_px: [u16; 2],
pub hide_help: bool,
pub ytdlp_path: String,
pub keys: crate::config::keymap::KeyPreset,
pub side_pane: SidePane,
pub library_layout: LibraryLayout,
pub initial_volume: f64,
pub volume_max: f64,
pub volume_step: f64,
pub seek_step: f64,
pub theme: String,
pub theme_colors: Vec<String>,
pub color_from_cover: bool,
pub accent_color: u8,
pub save_repeat_shuffle: bool,
pub autoplay_radio: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
visualizer_height: 6,
visualizer_bar_width: 2,
visualizer_mode: VisualizerMode::Bars,
cover_enabled: true,
cover_mode: CoverMode::Auto,
cell_px: [0, 0],
hide_help: false,
ytdlp_path: String::new(),
keys: crate::config::keymap::KeyPreset::Kew,
side_pane: SidePane::Queue,
library_layout: LibraryLayout::Columns,
initial_volume: 100.0,
volume_max: 100.0,
volume_step: 5.0,
seek_step: 5.0,
theme: crate::theme::COVER.to_string(),
theme_colors: Vec::new(),
color_from_cover: true,
accent_color: 6,
save_repeat_shuffle: false,
autoplay_radio: true,
}
}
}
impl Config {
pub fn load(dir: &Path) -> Self {
let path = dir.join("config.toml");
let Ok(raw) = std::fs::read_to_string(&path) else {
return Self::default();
};
match toml::from_str(&raw) {
Ok(c) => c,
Err(e) => {
eprintln!("ytkew: ignoring bad config.toml: {e}");
Self::default()
}
}
}
pub fn write_default_if_missing(dir: &Path) -> anyhow::Result<()> {
let path = dir.join("config.toml");
if path.exists() {
return Ok(());
}
std::fs::create_dir_all(dir)?;
std::fs::write(path, DEFAULT_CONFIG_TOML)?;
Ok(())
}
}
const DEFAULT_CONFIG_TOML: &str = r##"# ytkew configuration. Edit freely -- ytkew only reads this file.
# Runtime state (volume, shuffle, repeat) is kept separately in state.toml.
visualizer_height = 6
visualizer_bar_width = 2
visualizer_mode = "bars" # bars | braille | off
cover_mode = "auto" # which renderer to use for album art:
# auto - kitty if available, else blocks
# kitty - kitty graphics protocol
# sixel - sixel (needs an accurate cell_px)
# blocks - truecolor half-blocks, works anywhere
# `b` shows and hides the art; it does not
# change the renderer. Cycle renderers from the
# escape menu.
cell_px = [0, 0] # cell size in px for sixel. [0,0] = unset.
# Easiest way to set it: run ytkew, press `b`
# until you see sixel, then `[` and `]` to
# resize until it fits. It saves automatically.
side_pane = "queue" # off | queue | library -- what to show beside
# the now-playing column on a wide terminal
library_layout = "columns" # columns | tree -- columns puts each level
# side by side, file-manager style, and falls
# back to the tree when the pane is too narrow
cover_enabled = true
# Colours. "cover" takes them from the album art, which is the default and
# what kew does. Or pick a built-in:
# gruvbox nord dracula catppuccin tokyonight
# everforest rosepine solarized matrix mono
# Add your own by dropping a .toml file in themes/ -- see themes/README.txt.
# esc -> options -> theme switches at runtime and remembers your choice.
theme = "cover"
# With theme = "custom", these three are borders, secondary text, accent.
# theme = "custom"
# theme_colors = ["#504945", "#d5c4a1", "#fabd2f"]
accent_color = 6 # ANSI index, used only as a last resort
# ytdlp_path = "" # where to find yt-dlp. Empty searches PATH,
# trying yt-dlp then youtube-dl. Set it if
# yours lives somewhere unusual.
keys = "kew" # kew | vim -- vim swaps navigation for vim
# motions (gg, G, ctrl+d/u, dd, x, J/K) and
# moves next/prev track to H/L
initial_volume = 100.0 # only applies before state.toml exists
volume_max = 100.0 # raise to at most 130 to allow boosting quiet
# tracks; above 100 mpv adds plain digital gain
# with nothing to catch the peaks, so loud
# material will clip and sound fuzzy
volume_step = 5.0
seek_step = 5.0
autoplay_radio = true # append a radio mix behind a played search hit
save_repeat_shuffle = false # remember shuffle/repeat across restarts
hide_help = false
"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_round_trips_through_toml() {
let c = Config::default();
let s = toml::to_string_pretty(&c).unwrap();
let back: Config = toml::from_str(&s).unwrap();
assert_eq!(back.visualizer_height, c.visualizer_height);
assert_eq!(back.accent_color, c.accent_color);
}
#[test]
fn shipped_default_config_actually_parses() {
let c: Config = toml::from_str(DEFAULT_CONFIG_TOML)
.expect("the shipped default config must deserialize");
assert_eq!(c.visualizer_height, 6);
assert_eq!(c.accent_color, 6);
assert!(c.color_from_cover);
assert!(c.autoplay_radio);
}
#[test]
fn partial_config_keeps_defaults_for_everything_else() {
let c: Config = toml::from_str("accent_color = 1\ncolor_from_cover = false\n").unwrap();
assert_eq!(c.accent_color, 1);
assert!(!c.color_from_cover);
assert_eq!(c.visualizer_height, 6);
assert_eq!(c.volume_step, 5.0);
}
#[test]
fn unknown_config_keys_are_ignored_not_fatal() {
let c: Config =
toml::from_str("cover_ansi = true\nhide_logo = true\naccent_color = 2\n").unwrap();
assert_eq!(c.accent_color, 2);
}
#[test]
fn writing_defaults_never_clobbers_an_existing_config() {
let dir = std::env::temp_dir().join(format!("ytkew-cfg-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, "# my comments\naccent_color = 9\n").unwrap();
Config::write_default_if_missing(&dir).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(
after.contains("# my comments"),
"user comments must survive"
);
assert!(after.contains("accent_color = 9"));
let _ = std::fs::remove_dir_all(&dir);
}
}