diff --git a/AGENTS.md b/AGENTS.md index 64c4ad0..e1fd6d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e - `download.rs` — `download_selected_episodes` (async download + ID3 tagging); `sanitize_filename` shared `pub(crate)` helper - `ui/` — rendering, split per screen: - `theme.rs` — Dracula color palette - - `banner.rs` — the podcast-list banner: mascot and wordmark art, and `draw_banner`, which left-aligns them in a centered box and animates a one-shot typewriter reveal plus a periodic blink, both pure functions of `App::banner_started.elapsed()` + - `banner.rs` — the podcast-list banner: mascot art, both wordmarks, and `draw_banner`, which left-aligns them in a centered box and animates a one-shot typewriter reveal plus a periodic blink, both pure functions of `App::banner_started.elapsed()`. `banner_style` picks `WORDMARK_JOINED` (box-drawing strokes that meet across cell edges) or `WORDMARK_ASCII`; both place the letters in the same columns. - `widgets.rs` — shared helpers (`styled_block`, `key_hint`, `hint_bar`, `render_centered_dialog`, `format_date`) - `podcast_list.rs` — render podcast list screen - `episode_select.rs` — render episode selection with checkboxes @@ -60,7 +60,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e 1. **PodcastList** — podcast list with latest-episode preview; `j/k` or arrows move, `Enter` opens episodes, `r` refresh feeds, `c` config, `e` edit selected podcast, `D` delete prompt (confirm with `y`/`D`, cancel `Esc`; `Enter` is excluded here because it opens a podcast on this screen and deletion is irreversible); `Home`/`g`, `End`/`G`, `PageUp`/`PageDown` jump and page 2. **EpisodeSelect** — checkboxes per episode, `Space` toggle, `a` all, `s` sort, `o` open podcast folder, `Enter` download (if any selected), `Esc` back 3. **Downloading** — progress gauge and log; `Esc` returns when finished or on error -4. **Config** — menu: add podcast, download folder, new-podcast defaults, edit podcasts list, open `config.json`, open `podcasts.json`, open download folder; dialog and directory text-edit modes; `Esc` back to podcast list in navigate mode; `r` on download-folder row restores default path +4. **Config** — menu: add podcast, download folder, default mode, banner style, new-podcast defaults, edit podcasts list, open `config.json`, open `podcasts.json`, open download folder; dialog and directory text-edit modes; `Esc` back to podcast list in navigate mode; `r` on download-folder row restores default path 5. **EditPodcastSelect** — pick a podcast; `Enter` opens editor, `Esc` to config 6. **EditPodcast** — field list for one podcast or template; text / bool / usize editing; `s` save, `r` restore field default, `R` reset all on the template, `Esc` discard in navigate mode; `Ctrl+C` exit with discard confirmation when dirty; `Ctrl+V` paste while editing text 7. **AddPodcast** — multi-step wizard (`WIZARD_STEPS` in `add_podcast_wizard.rs`); first step is Feed URL; pressing Enter on it shows a **mode-select overlay** (`1`/`m` Manual, `2`/`p` Prepopulate from feed); Prepopulate fetches channel metadata and pre-fills Name, Album Name (sanitized), and Artist; `Esc` cancels/returns at each stage; `Ctrl+V` on text steps @@ -84,7 +84,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e **Data flow:** -1. `load_config()` reads from `/.config/AgentP/` (via `dirs`); on Windows the same relative path is typically `%USERPROFILE%\.config\AgentP\`. `config.json` holds `download_dir_location`, `default_podcast`, and `default_mode`; `podcasts.json` holds the podcast list. Missing files are created (demo podcasts from `example.podcasts.json` when appropriate). `example.podcasts.json` in the repo is the template for the podcast list shape. +1. `load_config()` reads from `/.config/AgentP/` (via `dirs`); on Windows the same relative path is typically `%USERPROFILE%\.config\AgentP\`. `config.json` holds `download_dir_location`, `default_podcast`, `default_mode`, and `banner_style`; `podcasts.json` holds the podcast list. Missing files are created (demo podcasts from `example.podcasts.json` when appropriate). `example.podcasts.json` in the repo is the template for the podcast list shape. 2. On TUI startup, `spawn_latest_fetches` runs `get_last_podcast_name` per podcast on `tokio::spawn`; results arrive on an unbounded `mpsc` channel consumed with `try_recv` in the main loop. 3. Choosing a podcast spawns `fetch_episode_list`; results arrive on a separate unbounded `mpsc` channel as `Ok(episodes)` / `Err`. 4. `download_selected_episodes()` writes under `//` with optional ID3 tags; `effective_album_name()` falls back to podcast name when album_name is empty. Progress uses `DownloadEvent` on `mpsc`. diff --git a/CLAUDE.md b/CLAUDE.md index 6e28e30..c3f8402 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e - `download.rs` — `download_selected_episodes` (async download + ID3 tagging); `sanitize_filename` shared `pub(crate)` helper - `ui/` — rendering, split per screen: - `theme.rs` — Dracula color palette - - `banner.rs` — the podcast-list banner: mascot and wordmark art, and `draw_banner`, which left-aligns them in a centered box and animates a one-shot typewriter reveal plus a periodic blink, both pure functions of `App::banner_started.elapsed()` + - `banner.rs` — the podcast-list banner: mascot art, both wordmarks, and `draw_banner`, which left-aligns them in a centered box and animates a one-shot typewriter reveal plus a periodic blink, both pure functions of `App::banner_started.elapsed()`. `banner_style` picks `WORDMARK_JOINED` (box-drawing strokes that meet across cell edges) or `WORDMARK_ASCII`; both place the letters in the same columns. - `widgets.rs` — shared helpers (`styled_block`, `key_hint`, `hint_bar`, `render_centered_dialog`, `format_date`) - `podcast_list.rs` — render podcast list screen - `episode_select.rs` — render episode selection with checkboxes @@ -60,7 +60,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e 1. **PodcastList** — podcast list with latest-episode preview; `j/k` or arrows move, `Enter` opens episodes, `r` refresh feeds, `c` config, `e` edit selected podcast, `D` delete prompt (confirm with `y`/`D`, cancel `Esc`; `Enter` is excluded here because it opens a podcast on this screen and deletion is irreversible); `Home`/`g`, `End`/`G`, `PageUp`/`PageDown` jump and page 2. **EpisodeSelect** — checkboxes per episode, `Space` toggle, `a` all, `s` sort, `o` open podcast folder, `Enter` download (if any selected), `Esc` back 3. **Downloading** — progress gauge and log; `Esc` returns when finished or on error -4. **Config** — menu: add podcast, download folder, new-podcast defaults, edit podcasts list, open `config.json`, open `podcasts.json`, open download folder; dialog and directory text-edit modes; `Esc` back to podcast list in navigate mode; `r` on download-folder row restores default path +4. **Config** — menu: add podcast, download folder, default mode, banner style, new-podcast defaults, edit podcasts list, open `config.json`, open `podcasts.json`, open download folder; dialog and directory text-edit modes; `Esc` back to podcast list in navigate mode; `r` on download-folder row restores default path 5. **EditPodcastSelect** — pick a podcast; `Enter` opens editor, `Esc` to config 6. **EditPodcast** — field list for one podcast or template; text / bool / usize editing; `s` save, `r` restore field default, `R` reset all on the template, `Esc` discard in navigate mode; `Ctrl+C` exit with discard confirmation when dirty; `Ctrl+V` paste while editing text 7. **AddPodcast** — multi-step wizard (`WIZARD_STEPS` in `add_podcast_wizard.rs`); first step is Feed URL; pressing Enter on it shows a **mode-select overlay** (`1`/`m` Manual, `2`/`p` Prepopulate from feed); Prepopulate fetches channel metadata and pre-fills Name, Album Name (sanitized), and Artist; `Esc` cancels/returns at each stage; `Ctrl+V` on text steps @@ -84,7 +84,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e **Data flow:** -1. `load_config()` reads from `/.config/AgentP/` (via `dirs`); on Windows the same relative path is typically `%USERPROFILE%\.config\AgentP\`. `config.json` holds `download_dir_location`, `default_podcast`, and `default_mode`; `podcasts.json` holds the podcast list. Missing files are created (demo podcasts from `example.podcasts.json` when appropriate). `example.podcasts.json` in the repo is the template for the podcast list shape. +1. `load_config()` reads from `/.config/AgentP/` (via `dirs`); on Windows the same relative path is typically `%USERPROFILE%\.config\AgentP\`. `config.json` holds `download_dir_location`, `default_podcast`, `default_mode`, and `banner_style`; `podcasts.json` holds the podcast list. Missing files are created (demo podcasts from `example.podcasts.json` when appropriate). `example.podcasts.json` in the repo is the template for the podcast list shape. 2. On TUI startup, `spawn_latest_fetches` runs `get_last_podcast_name` per podcast on `tokio::spawn`; results arrive on an unbounded `mpsc` channel consumed with `try_recv` in the main loop. 3. Choosing a podcast spawns `fetch_episode_list`; results arrive on a separate unbounded `mpsc` channel as `Ok(episodes)` / `Err`. 4. `download_selected_episodes()` writes under `//` with optional ID3 tags; `effective_album_name()` falls back to podcast name when album_name is empty. Progress uses `DownloadEvent` on `mpsc`. diff --git a/README.md b/README.md index e0ca1d4..b69eac3 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Deleting is the one confirmation that does not take `Enter`. `Enter` opens a pod ### Config menu -The menu has eight rows: **Add New Podcast**, **Download Folder**, **Default Mode**, **New Podcast Defaults**, **Edit Existing Podcasts**, **Open Config File**, **Open Podcasts Config File**, and **Open Download Folder**. +The menu has nine rows: **Add New Podcast**, **Download Folder**, **Default Mode**, **Banner Style**, **New Podcast Defaults**, **Edit Existing Podcasts**, **Open Config File**, **Open Podcasts Config File**, and **Open Download Folder**. | Key | Action | |---|---| @@ -248,6 +248,7 @@ agentp download --podcast 1 --all agentp config show agentp config set --download-dir ~/Podcasts agentp config set --default-mode cli +agentp config set --banner-style ascii agentp config path agentp open downloads @@ -261,9 +262,11 @@ agentp open podcast --podcast 1 --create Config lives under `$HOME/.config/AgentP/` (or `%USERPROFILE%\.config\AgentP\` on Windows). `agentp config path` prints the directory. -- `config.json` — download folder, the `default_podcast` template, and `default_mode` (`tui` or `cli`) +- `config.json` — download folder, the `default_podcast` template, `default_mode` (`tui` or `cli`), and `banner_style` (`joined` or `ascii`) - `podcasts.json` — your podcast list, as `{ "podcasts": [ ... ] }` +`banner_style` picks the TUI banner wordmark: `joined` (the default) draws the letters with box-drawing characters and needs a terminal that draws those itself, such as Windows Terminal or kitty; `ascii` is the plain fallback for terminals that leave gaps between the strokes, Warp among them. + ### Per-podcast options (`podcasts.json`) Each entry in `podcasts.json` has the same shape as `default_podcast` plus its identity fields. The **CLI flag** column applies to `podcast add` and `podcast edit`. diff --git a/src/app/config_screen.rs b/src/app/config_screen.rs index 3f58ba3..b13c3a0 100644 --- a/src/app/config_screen.rs +++ b/src/app/config_screen.rs @@ -3,7 +3,7 @@ use anyhow::Result; use ratatui::widgets::ListState; -use crate::podcast::{DefaultMode, save_config}; +use crate::podcast::{BannerStyle, DefaultMode, save_config}; use super::state::*; @@ -45,15 +45,18 @@ impl App { 2 => { let _ = self.config_toggle_default_mode(); } - 3 => self.enter_template_editor(), - 4 => self.enter_edit_podcast_select(), - 5 => { - let _ = self.config_open_file(); + 3 => { + let _ = self.config_toggle_banner_style(); } + 4 => self.enter_template_editor(), + 5 => self.enter_edit_podcast_select(), 6 => { + let _ = self.config_open_file(); + } + 7 => { let _ = self.config_open_podcasts_file(); } - 7 => self.open_download_folder(), + 8 => self.open_download_folder(), _ => {} } } @@ -87,13 +90,24 @@ impl App { Ok(()) } + /// Both toggles put the old value back when the save fails, so the screen + /// never shows a setting that is not on disk. pub fn config_toggle_default_mode(&mut self) -> Result<()> { - self.config.default_mode = match self.config.default_mode { + let previous = self.config.default_mode; + self.config.default_mode = match previous { DefaultMode::Tui => DefaultMode::Cli, DefaultMode::Cli => DefaultMode::Tui, }; - save_config(&self.config)?; - Ok(()) + save_config(&self.config).inspect_err(|_| self.config.default_mode = previous) + } + + pub fn config_toggle_banner_style(&mut self) -> Result<()> { + let previous = self.config.banner_style; + self.config.banner_style = match previous { + BannerStyle::Joined => BannerStyle::Ascii, + BannerStyle::Ascii => BannerStyle::Joined, + }; + save_config(&self.config).inspect_err(|_| self.config.banner_style = previous) } pub fn config_confirm_dir_change(&mut self) -> Result<()> { diff --git a/src/app/state.rs b/src/app/state.rs index d944d8a..edb3ea5 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -124,7 +124,7 @@ pub fn apply_list_jump(state: &mut ListState, len: usize, floor: usize, code: Ke } /// Number of items in the config menu. -pub const CONFIG_MENU_ITEM_COUNT: usize = 8; +pub const CONFIG_MENU_ITEM_COUNT: usize = 9; /// Total number of editable fields in the podcast editor. pub const PODCAST_FIELD_COUNT: usize = 14; /// Index of the first boolean field in the podcast editor. @@ -465,6 +465,7 @@ pub(crate) fn test_app_one_podcast() -> App { }], default_podcast: Podcast::default(), default_mode: crate::podcast::DefaultMode::default(), + banner_style: crate::podcast::BannerStyle::default(), }; App::new(config, None) } @@ -491,6 +492,7 @@ mod tests { ], default_podcast: Podcast::default(), default_mode: crate::podcast::DefaultMode::default(), + banner_style: crate::podcast::BannerStyle::default(), }; App::new(config, None) } diff --git a/src/cli/args.rs b/src/cli/args.rs index 4a11669..e604be8 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,6 +1,6 @@ use clap::{ArgGroup, Args, Parser, Subcommand}; -use crate::podcast::DefaultMode; +use crate::podcast::{BannerStyle, DefaultMode}; #[derive(Parser)] #[command( @@ -194,6 +194,12 @@ pub struct ConfigSetArgs { pub download_dir: Option, #[arg(long, value_enum, help = "Set the default launch mode (tui or cli)")] pub default_mode: Option, + #[arg( + long, + value_enum, + help = "Set the TUI banner wordmark (joined or ascii)" + )] + pub banner_style: Option, } #[derive(Subcommand)] diff --git a/src/cli/handlers.rs b/src/cli/handlers.rs index 839278f..7ce66ab 100644 --- a/src/cli/handlers.rs +++ b/src/cli/handlers.rs @@ -366,6 +366,7 @@ fn run_config(command: ConfigCommand) -> Result<()> { let (config, _) = load_config()?; println!("download_dir: {}", config.download_dir_location); println!("default_mode: {}", config.default_mode); + println!("banner_style: {}", config.banner_style); println!("podcasts: {}", config.podcasts.len()); println!(); println!("Default podcast template:"); @@ -416,6 +417,9 @@ fn run_config(command: ConfigCommand) -> Result<()> { if let Some(mode) = args.default_mode { config.default_mode = mode; } + if let Some(style) = args.banner_style { + config.banner_style = style; + } save_config(&config)?; println!("Config updated."); Ok(()) diff --git a/src/podcast/config_files.rs b/src/podcast/config_files.rs index 852d64e..baf4636 100644 --- a/src/podcast/config_files.rs +++ b/src/podcast/config_files.rs @@ -7,7 +7,7 @@ use std::io::BufReader; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use super::data::{Config, DefaultMode, Podcast}; +use super::data::{BannerStyle, Config, DefaultMode, Podcast}; #[derive(Debug, Clone, Serialize, Deserialize)] pub(super) struct ConfigFile { @@ -16,6 +16,8 @@ pub(super) struct ConfigFile { default_podcast: Podcast, #[serde(default)] default_mode: DefaultMode, + #[serde(default)] + banner_style: BannerStyle, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,6 +73,7 @@ pub fn save_config(config: &Config) -> Result<()> { download_dir_location: config.download_dir_location.clone(), default_podcast: config.default_podcast.clone(), default_mode: config.default_mode, + banner_style: config.banner_style, }; fs::write( dir.join("config.json"), @@ -103,6 +106,7 @@ pub fn load_config() -> Result<(Config, bool)> { download_dir_location: default_download_dir_location(), default_podcast: Podcast::default(), default_mode: DefaultMode::default(), + banner_style: BannerStyle::default(), }; fs::write(&cfg_path, serde_json::to_string_pretty(&config_file)?)?; freshly_created = true; @@ -132,6 +136,7 @@ pub fn load_config() -> Result<(Config, bool)> { podcasts: pods.podcasts, default_podcast: cfg.default_podcast, default_mode: cfg.default_mode, + banner_style: cfg.banner_style, }; config .podcasts @@ -166,6 +171,24 @@ mod tests { ); } + #[test] + fn config_without_a_banner_style_keeps_loading_and_defaults_to_joined() { + let cfg: ConfigFile = + serde_json::from_str(r#"{"download_dir_location": "/tmp/pods"}"#).unwrap(); + assert_eq!(cfg.banner_style, BannerStyle::Joined); + } + + #[test] + fn banner_style_round_trips_through_json() { + let cfg: ConfigFile = serde_json::from_str( + r#"{"download_dir_location": "/tmp/pods", "banner_style": "ascii"}"#, + ) + .unwrap(); + assert_eq!(cfg.banner_style, BannerStyle::Ascii); + let text = serde_json::to_string(&cfg).unwrap(); + assert!(text.contains("\"banner_style\":\"ascii\""), "{text}"); + } + #[test] fn default_download_dir_is_not_empty() { let dir = default_download_dir_location(); diff --git a/src/podcast/data.rs b/src/podcast/data.rs index 7d4ab79..f062334 100644 --- a/src/podcast/data.rs +++ b/src/podcast/data.rs @@ -21,6 +21,28 @@ impl fmt::Display for DefaultMode { } } +/// Which wordmark the TUI banner draws. +/// +/// `Joined` uses box-drawing characters whose strokes meet across cell edges, +/// which needs a terminal that draws those characters itself; `Ascii` is the +/// plain `/ \ | _` art that renders anywhere. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, clap::ValueEnum)] +#[serde(rename_all = "snake_case")] +pub enum BannerStyle { + #[default] + Joined, + Ascii, +} + +impl fmt::Display for BannerStyle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BannerStyle::Joined => write!(f, "joined"), + BannerStyle::Ascii => write!(f, "ascii"), + } + } +} + /// A single podcast with its feed URL, naming, and tag-override settings. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Podcast { @@ -112,6 +134,8 @@ pub struct Config { pub default_podcast: Podcast, #[serde(default)] pub default_mode: DefaultMode, + #[serde(default)] + pub banner_style: BannerStyle, } /// Metadata for a single episode parsed from an RSS feed. diff --git a/src/ui/banner.rs b/src/ui/banner.rs index 4a625be..473a88a 100644 --- a/src/ui/banner.rs +++ b/src/ui/banner.rs @@ -12,26 +12,51 @@ use ratatui::{ widgets::{Block, Padding, Paragraph}, }; +use crate::podcast::BannerStyle; + use super::theme::*; +/// The mascot. Its top rows sit one column right of the bottom two, so it +/// leans slightly to the right. const MIC: [&str; 6] = [ - " ▄██████▄ ", - " █ ▀ ▀ █ ", - " █ ▀▄▄▀ █ ", - " ▀██▄▄██▀ ", - " ██ ", - " ▄████▄ ", + " ▄██████▄ ", + " █ ▀ ▀ █ ", + " █ ▀▄▄▀ █ ", + " ▀██▄▄██▀ ", + " ██ ", + " ▄████▄ ", +]; + +/// Today's letter shapes drawn with joined line characters, so the strokes of +/// each letter connect instead of floating inside separate cells. Needs a +/// terminal that draws box-drawing characters itself. +const WORDMARK_JOINED: [&str; 6] = [ + " ▁ ▁ ▁▁▁▁", + " ╱ ╲ ▁▁ ▁ ▁▁▁ ▁ ▁▁ │ │▁│ ▁ ╲", + " ╱ ▁ ╲ ╱ ▁` │╱ ▁ ╲ '▁ ╲│ ▁▁│ │▁) │", + " ╱ ▁▁▁ ╲ (▁│ │ ▁▁╱ │ │ │ │▁│ ▁▁╱", + "╱▁╱ ╲▁╲▁▁, │╲▁▁▁│▁│ │▁│╲▁▁│▁│", + " │▁▁▁╱", ]; -const WORDMARK: [&str; 6] = [ - " _ _ ____", - " / \\ __ _ ___ _ __ | |_| _ \\", - " / _ \\ / _` |/ _ \\ '_ \\| __| |_) |", - "/ ___ \\ (_| | __/ | | | |_| __/", +/// The same letter shapes in plain ASCII, for terminals that draw box-drawing +/// characters from the font and leave gaps between the strokes. +const WORDMARK_ASCII: [&str; 6] = [ + " _ _ ____", + " / \\ __ _ ___ _ __ | |_| _ \\", + " / _ \\ / _` |/ _ \\ '_ \\| __| |_) |", + " / ___ \\ (_| | __/ | | | |_| __/", "/_/ \\_\\__, |\\___|_| |_|\\__|_|", " |___/", ]; +fn wordmark(style: BannerStyle) -> &'static [&'static str; 6] { + match style { + BannerStyle::Joined => &WORDMARK_JOINED, + BannerStyle::Ascii => &WORDMARK_ASCII, + } +} + const EYES_ROW: usize = 1; const VERTICAL_PADDING: u16 = 1; const REVEAL_COLUMNS_PER_SECOND: u128 = 50; @@ -62,16 +87,16 @@ fn prefix(row: &'static str, columns: usize) -> &'static str { .map_or(row, |(byte, _)| &row[..byte]) } -fn banner_lines(elapsed: Duration) -> Vec> { +fn banner_lines(elapsed: Duration, style: BannerStyle) -> Vec> { let mic_style = Style::default().fg(ACCENT).add_modifier(Modifier::BOLD); - let text_style = Style::default().fg(TITLE_FG); + let text_style = Style::default().fg(TITLE_FG).add_modifier(Modifier::BOLD); let visible = revealed_columns(elapsed); let closed = eyes_closed(elapsed); MIC.iter() - .zip(WORDMARK) + .zip(wordmark(style)) .enumerate() - .map(|(row, (&mic, text))| { + .map(|(row, (&mic, &text))| { let mic = if closed && row == EYES_ROW { Span::styled(mic.replace('▀', "─"), mic_style) } else { @@ -86,12 +111,12 @@ fn banner_lines(elapsed: Duration) -> Vec> { /// /// The lines are left-aligned inside a box as wide as the finished art, so rows /// of different lengths and the growing reveal never shift horizontally. -pub(super) fn draw_banner(frame: &mut Frame, area: Rect, elapsed: Duration) { - let width = (widest(&MIC) + widest(&WORDMARK)) as u16; +pub(super) fn draw_banner(frame: &mut Frame, area: Rect, elapsed: Duration, style: BannerStyle) { + let width = (widest(&MIC) + widest(wordmark(style))) as u16; let [area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) .areas(area); - let banner = Paragraph::new(banner_lines(elapsed)) + let banner = Paragraph::new(banner_lines(elapsed, style)) .block(Block::default().padding(Padding::vertical(VERTICAL_PADDING))); frame.render_widget(banner, area); } @@ -108,15 +133,27 @@ mod tests { #[test] fn reveal_types_the_wordmark_in_one_column_at_a_time() { - assert_eq!(prefix(WORDMARK[1], revealed_columns(Duration::ZERO)), ""); - assert_eq!( - prefix(WORDMARK[1], revealed_columns(Duration::from_millis(100))), - " / \\" - ); + let row = WORDMARK_JOINED[1]; + assert_eq!(prefix(row, revealed_columns(Duration::ZERO)), ""); assert_eq!( - prefix(WORDMARK[1], revealed_columns(Duration::from_secs(1))), - WORDMARK[1] + prefix(row, revealed_columns(Duration::from_millis(100))), + " ╱ " ); + assert_eq!(prefix(row, revealed_columns(Duration::from_secs(1))), row); + } + + #[test] + fn both_wordmarks_place_the_letters_in_the_same_columns() { + for (joined, ascii) in WORDMARK_JOINED.iter().zip(WORDMARK_ASCII) { + assert_eq!(joined.chars().count(), ascii.chars().count()); + } + } + + #[test] + fn the_plain_wordmark_avoids_characters_that_need_a_drawing_terminal() { + for row in WORDMARK_ASCII { + assert!(row.is_ascii(), "{row} should be plain ASCII"); + } } #[test] @@ -130,8 +167,8 @@ mod tests { #[test] fn blinking_changes_only_the_eyes_row() { - let open = banner_lines(Duration::from_secs(1)); - let closed = banner_lines(Duration::from_millis(3900)); + let open = banner_lines(Duration::from_secs(1), BannerStyle::Joined); + let closed = banner_lines(Duration::from_millis(3900), BannerStyle::Joined); for row in 0..MIC.len() { assert_eq!(open[row] == closed[row], row != EYES_ROW); } diff --git a/src/ui/config.rs b/src/ui/config.rs index d824268..43d0568 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -51,10 +51,11 @@ pub(super) fn draw_config(frame: &mut Frame, app: &mut App) { String::new() }; - let menu_labels: [&str; 8] = [ + let menu_labels: [&str; 9] = [ " Add New Podcast ", " Download Folder ", " Default Mode: ", + " Banner Style: ", " New Podcast Defaults ", " Edit Existing Podcasts ", " Open Config File ", @@ -83,13 +84,15 @@ pub(super) fn draw_config(frame: &mut Frame, app: &mut App) { )); } ListItem::new(Line::from(spans)) - } else if fi == 2 { + } else if fi == 2 || fi == 3 { + let value = if fi == 2 { + app.config.default_mode.to_string() + } else { + app.config.banner_style.to_string() + }; ListItem::new(Line::from(vec![ Span::styled(label.to_string(), Style::default().fg(label_color)), - Span::styled( - app.config.default_mode.to_string(), - Style::default().fg(ORANGE), - ), + Span::styled(value, Style::default().fg(ORANGE)), Span::styled(" →".to_string(), Style::default().fg(arrow_color)), ])) } else { diff --git a/src/ui/episode_select.rs b/src/ui/episode_select.rs index 57fc926..16793c8 100644 --- a/src/ui/episode_select.rs +++ b/src/ui/episode_select.rs @@ -201,7 +201,7 @@ mod tests { use ratatui::{Terminal, backend::TestBackend}; use crate::app::Screen; - use crate::podcast::{Config, DefaultMode, EpisodeInfo, Podcast}; + use crate::podcast::{BannerStyle, Config, DefaultMode, EpisodeInfo, Podcast}; use super::*; @@ -218,6 +218,7 @@ mod tests { }], default_podcast: Podcast::default(), default_mode: DefaultMode::default(), + banner_style: BannerStyle::default(), }; let mut app = App::new(config, None); app.screen = Screen::EpisodeSelect; diff --git a/src/ui/podcast_list.rs b/src/ui/podcast_list.rs index efb9757..0499fd6 100644 --- a/src/ui/podcast_list.rs +++ b/src/ui/podcast_list.rs @@ -82,7 +82,12 @@ pub(super) fn draw_podcast_list(frame: &mut Frame, app: &mut App) { ]) .split(frame.area()); - draw_banner(frame, chunks[0], app.banner_started.elapsed()); + draw_banner( + frame, + chunks[0], + app.banner_started.elapsed(), + app.config.banner_style, + ); if let Some(notice) = &app.config_notice { let notice_widget = Paragraph::new(Line::from(vec![ @@ -207,7 +212,7 @@ pub(super) fn draw_podcast_list(frame: &mut Frame, app: &mut App) { mod tests { use ratatui::{Terminal, backend::TestBackend}; - use crate::podcast::{Config, DefaultMode, Podcast}; + use crate::podcast::{BannerStyle, Config, DefaultMode, Podcast}; use super::*; @@ -294,6 +299,7 @@ mod tests { .collect(), default_podcast: Podcast::default(), default_mode: DefaultMode::default(), + banner_style: BannerStyle::default(), }; let mut app = App::new(config, None); app.latest_episodes = podcasts