1use 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
13pub struct App {
17 pub system: SonosSystem,
18 pub navigation: Navigation,
19 pub should_quit: bool,
20 pub dirty: bool,
21 #[allow(dead_code)] pub config: Config,
23 pub theme: Theme,
24 pub status_message: Option<String>,
26 pub terminal_width: u16,
28 pub picker: RefCell<Option<Picker>>,
32 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, config,
45 theme,
46 status_message: None,
47 terminal_width: 80, picker: RefCell::new(picker),
49 image_loader: ImageLoader::new(),
50 })
51 }
52}
53
54pub 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 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)] SpeakerDetail { speaker_id: SpeakerId },
122}
123
124#[derive(Clone, Debug, Default)]
126pub struct HomeGroupsState {
127 pub selected_index: usize,
128}
129
130#[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}