1use crate::config::Config;
4use crate::tui::theme::Theme;
5use sonos_sdk::{GroupId, SonosSystem, SpeakerId};
6
7pub struct App {
11 pub system: SonosSystem,
12 pub navigation: Navigation,
13 pub should_quit: bool,
14 pub dirty: bool,
15 #[allow(dead_code)] pub config: Config,
17 pub theme: Theme,
18 pub status_message: Option<String>,
20 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, config,
33 theme,
34 status_message: None,
35 terminal_width: 80, })
37 }
38}
39
40pub 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 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)] SpeakerDetail {
106 speaker_id: SpeakerId,
107 },
108}
109
110#[derive(Clone, Debug, Default)]
112pub struct HomeGroupsState {
113 pub selected_index: usize,
114}
115
116#[derive(Clone, Debug, Default)]
118pub struct HomeSpeakersState {
119 pub selected_index: usize,
120 pub modal: Option<ModalState>,
122}
123
124#[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}