Skip to main content

sonos_cli/tui/
app.rs

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