From 9b4dae14f073a51597528effa15fbc8384a5c7b5 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:00:41 +0200 Subject: [PATCH 1/2] feat: add banner gradient toggle that follows terminal palette (#336) Add behavior.banner_gradient (Settings -> Theme -> Banner Gradient). When off, the home banner is drawn in the theme's banner color instead of the animated RGB gradient, so with the Terminal (ANSI) preset the banner follows the terminal palette live (pywal etc.). The Terminal preset defaults the gradient off; an explicit config value wins. Preset cycling in Settings syncs the toggle, and the Home fast animation tick is skipped when the banner is static. --- CHANGELOG.md | 2 ++ src/core/app.rs | 28 +++++++++++++++++++ src/core/user_config.rs | 52 ++++++++++++++++++++++++++++++++++++ src/tui/handlers/settings.rs | 37 +++++++++++++++++++++++++ src/tui/runner.rs | 6 +++-- src/tui/ui/home.rs | 46 ++++++++++++++++++++++++++++--- 6 files changed, 166 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b26854..74fccd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **Banner Gradient toggle**: A new setting (Settings → Theme → Banner Gradient, or `behavior.banner_gradient` in `config.yml`) turns off the home banner's animated RGB gradient and draws it in the theme's banner color instead. The Terminal (ANSI) preset defaults the gradient off so the banner follows the terminal palette out of the box — tools like pywal recolor it live along with the rest of the UI; an explicit `banner_gradient:` in the config overrides the preset default either way ([#336](https://github.com/LargeModGames/spotatui/issues/336)). + - **Shuffle and repeat for Local Files, Subsonic, and YouTube**: The shuffle (`Ctrl+s`) and repeat (`Ctrl+r`) keys now work when one of the decoded sources owns playback, not just on Spotify. Repeat cycles Off → All → Track exactly as it does for Spotify: **All** wraps the context from the last track back to the first, **Track** replays the current song. Shuffle reorders in place and keeps the currently playing song playing (it becomes the new first track), so toggling it never interrupts audio; turning it back off restores the original order and keeps your place in it. Both modes, plus the shuffle order itself, survive a restart along with the rest of the playback session in `last_session.yml`. The modes are also routed through MPRIS, so `playerctl shuffle`/`loop` and desktop media controls drive them. Internet radio and native queue slots have no track queue of their own, so their playbar and MPRIS snapshot drop the shuffle/repeat controls entirely rather than showing inert ones — which is why the default `format.playbar_status_source` template gained self-contained `{shuffle}`/`{repeat}` segments (see `docs/configuration.md`). ### Fixed diff --git a/src/core/app.rs b/src/core/app.rs index 11281029..579f65d4 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -6130,6 +6130,13 @@ impl App { description: "Color for banner text".to_string(), value: SettingValue::Color(color_to_string(self.user_config.theme.banner)), }, + SettingItem { + id: "behavior.banner_gradient".to_string(), + name: "Banner Gradient".to_string(), + description: "Animated RGB gradient on the home banner; off uses the Banner Color" + .to_string(), + value: SettingValue::Bool(self.user_config.behavior.banner_gradient), + }, SettingItem { id: "theme.hint".to_string(), name: "Hint Color".to_string(), @@ -6277,6 +6284,11 @@ impl App { self.user_config.behavior.enable_text_emphasis = *v; } } + "behavior.banner_gradient" => { + if let SettingValue::Bool(v) = &setting.value { + self.user_config.behavior.banner_gradient = *v; + } + } "behavior.show_loading_indicator" => { if let SettingValue::Bool(v) = &setting.value { self.user_config.behavior.show_loading_indicator = *v; @@ -6933,6 +6945,22 @@ impl App { } } } + + /// Sync the Banner Gradient toggle to a newly selected preset's default + /// (Terminal defaults to off so the banner follows the terminal palette). + /// Custom keeps whatever the user chose. + pub fn sync_banner_gradient_setting(&mut self, preset: crate::core::user_config::ThemePreset) { + if preset == crate::core::user_config::ThemePreset::Custom { + return; + } + if let Some(setting) = self + .settings_items + .iter_mut() + .find(|s| s.id == "behavior.banner_gradient") + { + setting.value = SettingValue::Bool(preset.default_banner_gradient()); + } + } } #[cfg(test)] diff --git a/src/core/user_config.rs b/src/core/user_config.rs index 9141b740..b9d11b84 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -263,6 +263,13 @@ impl ThemePreset { presets[prev_idx] } + /// Default banner-gradient state for this preset. The Terminal preset + /// exists to follow the terminal's ANSI palette live, which the gradient's + /// fixed RGB colors would defeat, so it defaults to a solid banner. + pub fn default_banner_gradient(&self) -> bool { + !matches!(self, ThemePreset::Terminal) + } + /// Get the theme colors for this preset pub fn to_theme(self) -> Theme { match self { @@ -776,6 +783,7 @@ pub struct BehaviorConfigString { pub tick_rate_milliseconds: Option, pub animation_tick_rate_milliseconds: Option, pub enable_text_emphasis: Option, + pub banner_gradient: Option, pub show_loading_indicator: Option, pub enforce_wide_search_bar: Option, pub group_folders_first: Option, @@ -855,6 +863,9 @@ pub struct BehaviorConfig { pub tick_rate_milliseconds: u64, pub animation_tick_rate_milliseconds: u64, pub enable_text_emphasis: bool, + /// When false, the home banner is drawn in the theme's banner color instead + /// of the animated RGB gradient, so ANSI palettes (e.g. pywal) restyle it live. + pub banner_gradient: bool, pub show_loading_indicator: bool, pub enforce_wide_search_bar: bool, pub group_folders_first: bool, @@ -1168,6 +1179,7 @@ impl UserConfig { tick_rate_milliseconds: DEFAULT_TICK_RATE_MILLISECONDS, animation_tick_rate_milliseconds: DEFAULT_ANIMATION_TICK_RATE_MILLISECONDS, enable_text_emphasis: true, + banner_gradient: true, show_loading_indicator: true, enforce_wide_search_bar: false, group_folders_first: false, @@ -1401,6 +1413,15 @@ impl UserConfig { Ok(()) } + /// Resolve the effective banner-gradient state once both the behavior and + /// theme sections have loaded: an explicit `banner_gradient` in the config + /// always wins; otherwise the theme preset decides (the Terminal preset + /// defaults to a solid banner so it can follow the terminal palette). + fn resolve_banner_gradient(&mut self, explicit: Option) { + self.behavior.banner_gradient = + explicit.unwrap_or_else(|| self.current_preset.default_banner_gradient()); + } + pub fn load_behaviorconfig(&mut self, behavior_config: BehaviorConfigString) -> Result<()> { if let Some(behavior_string) = behavior_config.seek_milliseconds { self.behavior.seek_milliseconds = behavior_string; @@ -1452,6 +1473,10 @@ impl UserConfig { self.behavior.enable_text_emphasis = text_emphasis; } + if let Some(banner_gradient) = behavior_config.banner_gradient { + self.behavior.banner_gradient = banner_gradient; + } + if let Some(loading_indicator) = behavior_config.show_loading_indicator { self.behavior.show_loading_indicator = loading_indicator; } @@ -1958,12 +1983,17 @@ impl UserConfig { self.load_keybindings(keybindings)?; } + let explicit_banner_gradient = config_yml + .behavior + .as_ref() + .and_then(|behavior| behavior.banner_gradient); if let Some(behavior) = config_yml.behavior { self.load_behaviorconfig(behavior)?; } if let Some(theme) = config_yml.theme { self.load_theme(theme)?; } + self.resolve_banner_gradient(explicit_banner_gradient); if let Some(plugin_commands) = config_yml.plugin_commands { self.load_plugin_commands(plugin_commands); } @@ -2044,6 +2074,7 @@ impl UserConfig { tick_rate_milliseconds: Some(self.behavior.tick_rate_milliseconds), animation_tick_rate_milliseconds: Some(self.behavior.animation_tick_rate_milliseconds), enable_text_emphasis: Some(self.behavior.enable_text_emphasis), + banner_gradient: Some(self.behavior.banner_gradient), show_loading_indicator: Some(self.behavior.show_loading_indicator), enforce_wide_search_bar: Some(self.behavior.enforce_wide_search_bar), group_folders_first: Some(self.behavior.group_folders_first), @@ -2615,6 +2646,27 @@ mod tests { } } + #[test] + fn banner_gradient_defaults_off_for_terminal_preset_unless_explicit() { + use super::{ThemePreset, UserConfig}; + + let mut config = UserConfig::new(); + + // No explicit config value: the preset decides + config.current_preset = ThemePreset::Terminal; + config.resolve_banner_gradient(None); + assert!(!config.behavior.banner_gradient); + + config.current_preset = ThemePreset::Default; + config.resolve_banner_gradient(None); + assert!(config.behavior.banner_gradient); + + // An explicit value always wins over the preset default + config.current_preset = ThemePreset::Terminal; + config.resolve_banner_gradient(Some(true)); + assert!(config.behavior.banner_gradient); + } + #[test] fn test_reserved_key() { use super::check_reserved_keys; diff --git a/src/tui/handlers/settings.rs b/src/tui/handlers/settings.rs index 7df69d2b..3d328d1c 100644 --- a/src/tui/handlers/settings.rs +++ b/src/tui/handlers/settings.rs @@ -411,6 +411,7 @@ fn enter_edit_mode(app: &mut App) { p => p.to_theme(), }; app.sync_theme_color_settings(&preview_theme); + app.sync_banner_gradient_setting(next); return; } @@ -466,6 +467,7 @@ fn handle_preset_edit(key: Key, app: &mut App) { p => p.to_theme(), }; app.sync_theme_color_settings(&preview_theme); + app.sync_banner_gradient_setting(next); } } } @@ -530,6 +532,41 @@ mod tests { .expect("expected a boolean setting") } + #[test] + fn cycling_preset_to_terminal_turns_banner_gradient_setting_off() { + let mut app = App::default(); + app.settings_category = SettingsCategory::Theme; + open_settings(&mut app); + + app.settings_selected_index = app + .settings_items + .iter() + .position(|s| s.id == "theme.preset") + .expect("expected the preset setting"); + + fn banner_gradient(app: &App) -> bool { + match &app + .settings_items + .iter() + .find(|s| s.id == "behavior.banner_gradient") + .expect("expected the banner gradient setting") + .value + { + SettingValue::Bool(v) => *v, + other => panic!("expected a bool setting, got {other:?}"), + } + } + assert!(banner_gradient(&app)); + + // Default (Cyan) -> Terminal (ANSI): gradient defaults off + handler(Key::Enter, &mut app); + assert!(!banner_gradient(&app)); + + // Terminal (ANSI) -> Pookie Pink: gradient default restored + handler(Key::Enter, &mut app); + assert!(banner_gradient(&app)); + } + #[test] fn esc_without_changes_exits_settings_without_prompt() { let mut app = App::default(); diff --git a/src/tui/runner.rs b/src/tui/runner.rs index 0ca7bd92..aeae769c 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -677,8 +677,10 @@ pub async fn start_ui( let current_route = app.get_current_route(); // The banner animates whenever the Home screen is displayed, regardless // of which block has focus (on Home the focused block is usually Empty - // or Library, not Home), so gate the fast tick on the route. - let animation_active = current_route.id == RouteId::Home + // or Library, not Home), so gate the fast tick on the route. A disabled + // banner gradient renders statically and needs no fast tick. + let animation_active = (current_route.id == RouteId::Home + && app.user_config.behavior.banner_gradient) || current_route.active_block == ActiveBlock::Analysis || app.liked_song_animation_frame.is_some(); let current_tick_rate = if animation_active { diff --git a/src/tui/ui/home.rs b/src/tui/ui/home.rs index 48577862..4fcb1c2c 100644 --- a/src/tui/ui/home.rs +++ b/src/tui/ui/home.rs @@ -65,12 +65,21 @@ pub fn draw_home(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { .border_style(get_color(highlight_state, app.user_config.theme)); f.render_widget(welcome, layout_chunk); - // Banner gradient is recomputed each frame for animation - let gradient_lines = build_banner_gradient_lines(&app.user_config.theme, app.animation_tick); + // Banner gradient is recomputed each frame for animation. When disabled, + // the banner uses the theme's banner color directly so ANSI palettes + // (e.g. pywal with the Terminal preset) restyle it live (#336). + let banner_text = if app.user_config.behavior.banner_gradient { + Text::from(build_banner_gradient_lines( + &app.user_config.theme, + app.animation_tick, + )) + } else { + Text::styled(BANNER, Style::default().fg(app.user_config.theme.banner)) + }; let base_changelog_lines = get_changelog_cache(&app.user_config.theme, changelog_area.width); // Contains the banner - let top_text = Paragraph::new(Text::from(gradient_lines)) + let top_text = Paragraph::new(banner_text) .style(app.user_config.theme.base_style()) .block(Block::default()); f.render_widget(top_text, banner_area); @@ -497,6 +506,37 @@ mod tests { } } + #[test] + fn banner_gradient_toggle_switches_between_rgb_and_theme_color() { + fn banner_fg(app: &App) -> Color { + let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); + terminal.draw(|f| draw_home(f, app, f.area())).unwrap(); + let buffer = terminal.backend().buffer(); + for y in 2..9u16 { + for x in 2..98u16 { + if let Some(cell) = buffer.cell((x, y)) { + if cell.symbol() == "█" { + return cell.fg; + } + } + } + } + panic!("no banner glyph found in banner rows"); + } + + let mut app = App::default(); + assert!( + matches!(banner_fg(&app), Color::Rgb(..)), + "gradient banner should render RGB colors" + ); + + // With the gradient off, the banner uses the theme's banner color as-is, + // so an ANSI color (Terminal preset) passes through to the terminal. + app.user_config.behavior.banner_gradient = false; + app.user_config.theme = crate::core::user_config::ThemePreset::Terminal.to_theme(); + assert_eq!(banner_fg(&app), app.user_config.theme.banner); + } + #[test] fn home_changelog_scroll_past_end_renders_blank() { let mut app = App::default(); From 532751b4c8a07deeab3c765f8c099b65d2eba2af Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:01:30 +0200 Subject: [PATCH 2/2] docs: describe banner_gradient in configuration.md --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6d8b9ea1..75ed5775 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -228,4 +228,4 @@ The ▶ now-playing marker attaches to the `title` column, or to the first colum ## Keybindings, theme, and plugins -The `keybindings:` section rebinds ~40 named actions (`back: q`, `next_track: n`, modifier syntax like `ctrl-s` / `alt-,`), and `theme:` sets a preset plus 16 individual color slots (`"R, G, B"` or named colors) — both are easiest to edit from the in-app Settings screen, which writes them back to this file. `plugin_commands:` maps extra keys to Lua plugin commands. See [`examples/config.example.yml`](../examples/config.example.yml) and the [scripting docs](scripting.md). +The `keybindings:` section rebinds ~40 named actions (`back: q`, `next_track: n`, modifier syntax like `ctrl-s` / `alt-,`), and `theme:` sets a preset plus 16 individual color slots (`"R, G, B"` or named colors) — both are easiest to edit from the in-app Settings screen, which writes them back to this file. `behavior.banner_gradient: false` draws the home-screen banner in the theme's banner color instead of the animated RGB gradient. The Terminal (ANSI) preset defaults this to `false` so the banner follows the terminal palette live (pywal etc.); every other preset defaults to `true`. An explicit `banner_gradient:` value in the config wins over the preset default. `plugin_commands:` maps extra keys to Lua plugin commands. See [`examples/config.example.yml`](../examples/config.example.yml) and the [scripting docs](scripting.md).