pulsedeck 0.2.0

A focused terminal internet radio player with fast search, saved stations, themes, visualizers, and resilient playback
use super::*;
use crate::action::Action;
use crate::ui::theme::ThemeName;

impl App {
    pub(super) fn handle_settings_action(&mut self, action: Action) {
        match action {
            Action::NextStation => {
                self.selected_setting_idx = (self.selected_setting_idx + 1) % SettingRow::COUNT;
            }
            Action::PrevStation => {
                self.selected_setting_idx = if self.selected_setting_idx == 0 {
                    SettingRow::COUNT - 1
                } else {
                    self.selected_setting_idx - 1
                };
            }
            Action::PlaySelected | Action::TogglePause if self.apply_selected_setting(true) => {
                self.save_library_or_notice("settings");
            }
            Action::StepSettingForward if self.apply_directional_setting(true) => {
                self.save_library_or_notice("settings");
            }
            Action::StepSettingBackward | Action::ToggleHelp
                if self.apply_directional_setting(false) =>
            {
                self.save_library_or_notice("settings");
            }
            Action::PlaySelected
            | Action::TogglePause
            | Action::StepSettingForward
            | Action::StepSettingBackward
            | Action::ToggleHelp => {}
            Action::ToggleSettings => {
                self.show_settings = false;
            }
            Action::Quit => {
                self.show_settings = false;
            }
            Action::Tick => {
                self.tick_count += 1;
                self.tick_notice();
                self.poll_audio_status();
                self.update_visualizer();
            }
            _ => {
                // Block all other actions while settings are open.
            }
        }
    }

    pub(super) fn selected_setting_row(&self) -> Option<SettingRow> {
        SettingRow::from_index(self.selected_setting_idx)
    }

    pub(super) fn apply_selected_setting(&mut self, forward: bool) -> bool {
        match self.selected_setting_row() {
            Some(SettingRow::Notifications) => {
                self.library.settings.notifications_enabled =
                    !self.library.settings.notifications_enabled;
                true
            }
            Some(SettingRow::AutoplayLast) => {
                self.library.settings.autoplay_last = !self.library.settings.autoplay_last;
                true
            }
            Some(SettingRow::OutputDevice) => {
                self.library.settings.output_device_name = step_output_device_preference(
                    self.library.settings.output_device_name.as_deref(),
                    &available_output_device_choices(),
                    forward,
                );
                self.sync_output_device();
                self.set_info_notice(format!(
                    "Audio output: {}",
                    output_device_display_name(self.library.settings.output_device_name.as_deref())
                ));
                true
            }
            Some(SettingRow::Theme) => {
                let current = ThemeName::from_key(&self.library.settings.theme);
                let next = step_choice(ThemeName::ALL, current, forward);
                self.library.settings.theme = next.key().to_string();
                crate::ui::theme::set_active(next);
                true
            }
            None => false,
        }
    }

    pub(super) fn sync_output_device(&self) {
        self.audio.send(crate::audio::AudioCommand::SetOutputDevice(
            self.library.settings.output_device_name.clone(),
        ));
    }

    fn apply_directional_setting(&mut self, forward: bool) -> bool {
        self.apply_selected_setting(forward)
    }
}

fn available_output_device_choices() -> Vec<String> {
    let mut choices = vec![crate::audio::DEFAULT_OUTPUT_DEVICE_LABEL.to_string()];
    choices.extend(crate::audio::list_output_device_names());
    choices
}

fn output_device_display_name(value: Option<&str>) -> String {
    crate::audio::output_device_display_name(value)
}

fn step_output_device_preference(
    current: Option<&str>,
    choices: &[String],
    forward: bool,
) -> Option<String> {
    if choices.is_empty() {
        return None;
    }

    let current_label = output_device_display_name(current);
    let current_index = choices
        .iter()
        .position(|choice| choice.eq_ignore_ascii_case(&current_label));

    let next_index = match (current_index, forward) {
        (Some(index), true) => (index + 1) % choices.len(),
        (Some(0), false) => choices.len() - 1,
        (Some(index), false) => index - 1,
        (None, true) => 0,
        (None, false) => choices.len() - 1,
    };

    let next = choices[next_index].trim();
    if next.eq_ignore_ascii_case(crate::audio::DEFAULT_OUTPUT_DEVICE_LABEL) {
        None
    } else {
        Some(next.to_string())
    }
}

fn step_choice<T: Copy + PartialEq>(choices: &[T], current: T, forward: bool) -> T {
    if choices.is_empty() {
        return current;
    }

    let Some(index) = choices.iter().position(|choice| *choice == current) else {
        return if forward {
            choices[0]
        } else {
            choices[choices.len() - 1]
        };
    };

    if forward {
        choices[(index + 1) % choices.len()]
    } else if index == 0 {
        choices[choices.len() - 1]
    } else {
        choices[index - 1]
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::favorites::Library;
    use crate::radio::Station;

    fn station(name: &str, url: &str) -> Station {
        Station {
            name: name.to_string(),
            url: url.to_string(),
            genre: "Synthwave".to_string(),
            country: "US".to_string(),
            bitrate: 128,
        }
    }

    fn test_app() -> App {
        App::new(Library::in_memory(vec![station("A", "http://a")]))
    }

    #[test]
    fn settings_blocks_play_selected() {
        let mut app = test_app();
        app.show_settings = true;
        app.selected_setting_idx = SettingRow::Notifications.index();
        let before = app.library.settings.notifications_enabled;

        app.update(Action::PlaySelected);

        assert_eq!(app.playing_url, None);
        assert_eq!(app.library.settings.notifications_enabled, !before);
    }

    #[test]
    fn settings_navigation_wraps_using_row_count() {
        let mut app = test_app();
        app.show_settings = true;
        app.selected_setting_idx = SettingRow::COUNT - 1;

        app.update(Action::NextStation);
        assert_eq!(app.selected_setting_idx, 0);

        app.update(Action::PrevStation);
        assert_eq!(app.selected_setting_idx, SettingRow::COUNT - 1);
    }

    #[test]
    fn each_setting_row_maps_from_its_index() {
        for row in SettingRow::ALL {
            assert_eq!(SettingRow::from_index(row.index()), Some(row));
        }
        assert_eq!(SettingRow::from_index(SettingRow::COUNT), None);
    }

    #[test]
    fn settings_forward_and_backward_cycle_theme() {
        let mut app = test_app();
        app.show_settings = true;
        app.selected_setting_idx = SettingRow::Theme.index();
        app.library.settings.theme = "Retrowave".to_string();

        app.update(Action::StepSettingForward);
        assert_eq!(app.library.settings.theme, "CatppuccinMocha");

        app.update(Action::StepSettingBackward);
        assert_eq!(app.library.settings.theme, "Retrowave");
    }

    #[test]
    fn settings_backward_wraps_theme() {
        let mut app = test_app();
        app.show_settings = true;
        app.selected_setting_idx = SettingRow::Theme.index();
        app.library.settings.theme = "Retrowave".to_string();

        app.update(Action::StepSettingBackward);

        assert_eq!(app.library.settings.theme, "CatppuccinLatte");
    }

    #[test]
    fn output_device_preference_cycles_default_and_devices() {
        let choices = vec![
            crate::audio::DEFAULT_OUTPUT_DEVICE_LABEL.to_string(),
            "Built-in Speakers".to_string(),
            "BlueZ Headphones".to_string(),
        ];

        assert_eq!(
            step_output_device_preference(None, &choices, true).as_deref(),
            Some("Built-in Speakers")
        );
        assert_eq!(
            step_output_device_preference(Some("Built-in Speakers"), &choices, true).as_deref(),
            Some("BlueZ Headphones")
        );
        assert_eq!(
            step_output_device_preference(Some("BlueZ Headphones"), &choices, true),
            None
        );
        assert_eq!(
            step_output_device_preference(None, &choices, false).as_deref(),
            Some("BlueZ Headphones")
        );
    }

    #[test]
    fn output_device_preference_handles_missing_saved_device() {
        let choices = vec![
            crate::audio::DEFAULT_OUTPUT_DEVICE_LABEL.to_string(),
            "Built-in Speakers".to_string(),
        ];

        assert_eq!(
            step_output_device_preference(Some("Missing Bluetooth"), &choices, true),
            None
        );
        assert_eq!(
            step_output_device_preference(Some("Missing Bluetooth"), &choices, false).as_deref(),
            Some("Built-in Speakers")
        );
    }

    #[test]
    fn output_device_display_name_uses_default_label_for_none() {
        assert_eq!(
            output_device_display_name(None),
            crate::audio::DEFAULT_OUTPUT_DEVICE_LABEL
        );
        assert_eq!(
            output_device_display_name(Some("BlueZ Headphones")),
            "BlueZ Headphones"
        );
    }

    #[test]
    fn settings_h_action_steps_backward_without_closing_popup() {
        let mut app = test_app();
        app.show_settings = true;
        app.selected_setting_idx = SettingRow::Theme.index();
        app.library.settings.theme = "CatppuccinMocha".to_string();

        app.update(Action::ToggleHelp);

        assert!(app.show_settings);
        assert_eq!(app.library.settings.theme, "Retrowave");
    }

    #[test]
    fn settings_blocks_search_entry() {
        let mut app = test_app();
        app.show_settings = true;

        app.update(Action::EnterSearch);

        assert_eq!(app.input_mode, InputMode::Normal);
        assert!(app.show_settings);
    }

    #[test]
    fn settings_quit_closes_settings_without_quitting_app() {
        let mut app = test_app();
        app.show_settings = true;

        app.update(Action::Quit);

        assert!(!app.show_settings);
        assert!(!app.should_quit);
    }

    #[test]
    fn settings_tick_still_polls_and_updates() {
        let mut app = test_app();
        app.show_settings = true;
        app.set_info_notice("hello");

        app.update(Action::Tick);

        assert_eq!(app.tick_count, 1);
        assert!(app.notice.is_some());
    }
}