Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
28 changes: 28 additions & 0 deletions src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down
52 changes: 52 additions & 0 deletions src/core/user_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -776,6 +783,7 @@ pub struct BehaviorConfigString {
pub tick_rate_milliseconds: Option<u64>,
pub animation_tick_rate_milliseconds: Option<u64>,
pub enable_text_emphasis: Option<bool>,
pub banner_gradient: Option<bool>,
pub show_loading_indicator: Option<bool>,
pub enforce_wide_search_bar: Option<bool>,
pub group_folders_first: Option<bool>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<bool>) {
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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions src/tui/handlers/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions src/tui/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 43 additions & 3 deletions src/tui/ui/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading