Skip to main content

sonos_cli/tui/
app.rs

1//! TUI application state and navigation types.
2
3use std::cell::RefCell;
4
5use ratatui_image::picker::Picker;
6
7use crate::config::Config;
8use crate::tui::image_loader::ImageLoader;
9use crate::tui::theme::Theme;
10use crate::tui::widgets::speaker_list::PickUpState;
11use sonos_sdk::{GroupId, SonosSystem, SpeakerId};
12
13/// Top-level TUI state. Owns the SDK handle and all UI state.
14///
15/// Screens read from `&App`; event handlers write to `&mut App`.
16pub struct App {
17    pub system: SonosSystem,
18    pub navigation: Navigation,
19    pub should_quit: bool,
20    pub dirty: bool,
21    #[allow(dead_code)] // used in future milestones
22    pub config: Config,
23    pub theme: Theme,
24    /// Inline status message (e.g. errors from speaker actions). Cleared on next key press.
25    pub status_message: Option<String>,
26    /// Terminal width cached from last render/resize, used for grid navigation.
27    pub terminal_width: u16,
28    /// Terminal image protocol picker, detected before entering raw mode.
29    /// `None` when album art is disabled or terminal detection failed.
30    /// `RefCell` because `new_resize_protocol()` requires `&mut Picker`.
31    pub picker: RefCell<Option<Picker>>,
32    /// Background image fetcher and cache for album art.
33    pub image_loader: ImageLoader,
34}
35
36impl App {
37    pub fn new(config: Config, theme: Theme, picker: Option<Picker>) -> anyhow::Result<Self> {
38        let system = SonosSystem::new()?;
39        Ok(Self {
40            system,
41            navigation: Navigation::new(),
42            should_quit: false,
43            dirty: true, // first frame always renders
44            config,
45            theme,
46            status_message: None,
47            terminal_width: 80, // updated on first render/resize
48            picker: RefCell::new(picker),
49            image_loader: ImageLoader::new(),
50        })
51    }
52}
53
54/// Stack-based navigation. The bottom of the stack is always Home.
55pub struct Navigation {
56    pub stack: Vec<Screen>,
57}
58
59impl Default for Navigation {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl Navigation {
66    pub fn new() -> Self {
67        Self {
68            stack: vec![Screen::Home {
69                tab: HomeTab::default(),
70                tab_focused: false,
71                groups_state: HomeGroupsState::default(),
72                speakers_state: SpeakerListScreenState::default(),
73            }],
74        }
75    }
76
77    pub fn current(&self) -> &Screen {
78        self.stack.last().expect("navigation stack is never empty")
79    }
80
81    pub fn current_mut(&mut self) -> &mut Screen {
82        self.stack
83            .last_mut()
84            .expect("navigation stack is never empty")
85    }
86
87    pub fn push(&mut self, screen: Screen) {
88        self.stack.push(screen);
89    }
90
91    /// Returns true if a screen was popped. Returns false if at root.
92    pub fn pop(&mut self) -> bool {
93        if self.stack.len() > 1 {
94            self.stack.pop();
95            true
96        } else {
97            false
98        }
99    }
100
101    pub fn at_root(&self) -> bool {
102        self.stack.len() == 1
103    }
104}
105
106#[derive(Clone, Debug)]
107pub enum Screen {
108    Home {
109        tab: HomeTab,
110        tab_focused: bool,
111        groups_state: HomeGroupsState,
112        speakers_state: SpeakerListScreenState,
113    },
114    GroupView {
115        group_id: GroupId,
116        tab: GroupTab,
117        tab_focused: bool,
118        speakers_state: SpeakerListScreenState,
119    },
120    #[allow(dead_code)] // used in future milestones
121    SpeakerDetail { speaker_id: SpeakerId },
122}
123
124/// UI state for the Home > Groups tab.
125#[derive(Clone, Debug, Default)]
126pub struct HomeGroupsState {
127    pub selected_index: usize,
128}
129
130/// Shared UI state for any speaker list (Home > Speakers or GroupView > Speakers).
131#[derive(Clone, Debug, Default)]
132pub struct SpeakerListScreenState {
133    pub selected_index: usize,
134    pub pick_up: Option<PickUpState>,
135}
136
137impl Screen {
138    pub fn speakers_state(&self) -> Option<&SpeakerListScreenState> {
139        match self {
140            Screen::Home { speakers_state, .. } => Some(speakers_state),
141            Screen::GroupView { speakers_state, .. } => Some(speakers_state),
142            _ => None,
143        }
144    }
145
146    pub fn speakers_state_mut(&mut self) -> Option<&mut SpeakerListScreenState> {
147        match self {
148            Screen::Home { speakers_state, .. } => Some(speakers_state),
149            Screen::GroupView { speakers_state, .. } => Some(speakers_state),
150            _ => None,
151        }
152    }
153}
154
155#[derive(Clone, Debug, Default, PartialEq, Eq)]
156pub enum HomeTab {
157    #[default]
158    Groups,
159    Speakers,
160}
161
162#[derive(Clone, Debug, Default, PartialEq, Eq)]
163pub enum GroupTab {
164    #[default]
165    NowPlaying,
166    Speakers,
167    Queue,
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn navigation_starts_at_home() {
176        let nav = Navigation::new();
177        assert!(nav.at_root());
178        assert!(matches!(nav.current(), Screen::Home { .. }));
179    }
180
181    #[test]
182    fn push_adds_to_stack() {
183        let mut nav = Navigation::new();
184        nav.push(Screen::SpeakerDetail {
185            speaker_id: SpeakerId::new("RINCON_TEST"),
186        });
187        assert!(!nav.at_root());
188        assert!(matches!(nav.current(), Screen::SpeakerDetail { .. }));
189    }
190
191    #[test]
192    fn pop_returns_to_previous() {
193        let mut nav = Navigation::new();
194        nav.push(Screen::SpeakerDetail {
195            speaker_id: SpeakerId::new("RINCON_TEST"),
196        });
197        assert!(nav.pop());
198        assert!(nav.at_root());
199        assert!(matches!(nav.current(), Screen::Home { .. }));
200    }
201
202    #[test]
203    fn pop_at_root_returns_false() {
204        let mut nav = Navigation::new();
205        assert!(!nav.pop());
206        assert!(nav.at_root());
207    }
208
209    #[test]
210    fn current_mut_allows_tab_switch() {
211        let mut nav = Navigation::new();
212        if let Screen::Home { ref mut tab, .. } = nav.current_mut() {
213            *tab = HomeTab::Speakers;
214        }
215        match nav.current() {
216            Screen::Home { tab, .. } => assert_eq!(*tab, HomeTab::Speakers),
217            _ => panic!("expected Home screen"),
218        }
219    }
220}