Skip to main content

nm_wifi/
app_state.rs

1use std::time::Instant;
2
3use crate::wifi::WifiNetwork;
4
5#[derive(PartialEq)]
6pub enum AppState {
7    Scanning,
8    NetworkList,
9    PasswordInput,
10    Connecting,
11    Disconnecting,
12    ConnectionResult,
13    Help,
14    NetworkDetails,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum OperationKind {
19    Connect,
20    Disconnect,
21}
22
23pub struct App {
24    pub networks: Vec<WifiNetwork>,
25    pub selected_index: usize,
26    pub state: AppState,
27    pub password_input: String,
28    pub selected_network: Option<WifiNetwork>,
29    pub status_message: String,
30    pub should_quit: bool,
31    pub connection_success: bool,
32    pub connection_error: Option<String>,
33    pub is_disconnect_operation: bool,
34    pub adapter_name: Option<String>,
35    pub network_count: usize,
36    pub last_scan_time: Option<Instant>,
37    pub connection_start_time: Option<Instant>,
38    pub password_visible: bool,
39}
40
41impl Default for App {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl App {
48    fn set_selected_index(&mut self, index: usize) {
49        self.selected_index = index;
50    }
51
52    pub fn new() -> App {
53        App {
54            networks: Vec::new(),
55            selected_index: 0,
56            state: AppState::Scanning,
57            password_input: String::new(),
58            selected_network: None,
59            status_message: "Scanning for networks...".to_string(),
60            should_quit: false,
61            connection_success: false,
62            connection_error: None,
63            is_disconnect_operation: false,
64            adapter_name: None,
65            network_count: 0,
66            last_scan_time: None,
67            connection_start_time: None,
68            password_visible: false,
69        }
70    }
71
72    pub fn next(&mut self) {
73        if !self.networks.is_empty() {
74            let i = if self.selected_index >= self.networks.len() - 1 {
75                0
76            } else {
77                self.selected_index + 1
78            };
79            self.set_selected_index(i);
80        }
81    }
82
83    pub fn previous(&mut self) {
84        if !self.networks.is_empty() {
85            let i = if self.selected_index == 0 {
86                self.networks.len() - 1
87            } else {
88                self.selected_index - 1
89            };
90            self.set_selected_index(i);
91        }
92    }
93
94    pub fn selected_network_in_list(&self) -> Option<&WifiNetwork> {
95        self.networks.get(self.selected_index)
96    }
97
98    pub fn begin_operation(
99        &mut self,
100        network: WifiNetwork,
101        operation: OperationKind,
102    ) {
103        self.selected_network = Some(network.clone());
104        self.is_disconnect_operation = operation == OperationKind::Disconnect;
105        self.connection_start_time = Some(Instant::now());
106        self.state = match operation {
107            OperationKind::Connect => AppState::Connecting,
108            OperationKind::Disconnect => AppState::Disconnecting,
109        };
110        self.status_message = match operation {
111            OperationKind::Connect => {
112                format!("Connecting to {}...", network.ssid)
113            }
114            OperationKind::Disconnect => {
115                format!("Disconnecting from {}...", network.ssid)
116            }
117        };
118    }
119
120    pub fn activate_selected_network(&mut self) {
121        let network = self.selected_network_in_list().cloned();
122
123        match network {
124            Some(network) if network.connected => {
125                self.begin_operation(network, OperationKind::Disconnect);
126            }
127            Some(network) if network.is_secured() => {
128                self.state = AppState::PasswordInput;
129                self.password_input.clear();
130                self.selected_network = Some(network);
131            }
132            Some(network) => {
133                self.begin_operation(network, OperationKind::Connect);
134            }
135            None => {}
136        }
137    }
138
139    pub fn add_char_to_password(&mut self, c: char) {
140        self.password_input.push(c);
141    }
142
143    pub fn remove_char_from_password(&mut self) {
144        self.password_input.pop();
145    }
146
147    pub fn confirm_password(&mut self) {
148        if let Some(network) = self.selected_network.clone() {
149            self.begin_operation(network, OperationKind::Connect);
150        }
151    }
152
153    pub fn quit(&mut self) {
154        self.should_quit = true;
155    }
156
157    pub fn finish_operation(&mut self, succeeded: bool, error: Option<String>) {
158        self.connection_success = succeeded;
159        self.connection_error = error;
160        self.status_message = match (self.is_disconnect_operation, succeeded) {
161            (true, true) => "Disconnected successfully!".to_string(),
162            (true, false) => "Disconnection failed".to_string(),
163            (false, true) => "Connected successfully!".to_string(),
164            (false, false) => "Connection failed".to_string(),
165        };
166        self.state = AppState::ConnectionResult;
167    }
168
169    pub fn back_to_network_list(&mut self) {
170        self.state = AppState::NetworkList;
171        self.connection_success = false;
172        self.connection_error = None;
173        self.password_input.clear();
174        self.password_visible = false;
175        self.is_disconnect_operation = false;
176        self.connection_start_time = None;
177    }
178
179    pub fn start_scan(&mut self) {
180        self.state = AppState::Scanning;
181        self.status_message = "Scanning for networks...".to_string();
182        self.networks.clear();
183        self.network_count = 0;
184        self.last_scan_time = None;
185        self.set_selected_index(0);
186    }
187
188    pub fn handle_scan_error(&mut self, error: impl std::fmt::Display) {
189        self.state = AppState::NetworkList;
190        self.network_count = self.networks.len();
191        self.last_scan_time = None;
192        self.status_message =
193            format!("Scan failed: {}. Press r to retry.", error);
194    }
195
196    pub fn update_selection_after_rescan(&mut self) {
197        if let Some(selected_network) = &self.selected_network {
198            if let Some(new_index) = self
199                .networks
200                .iter()
201                .position(|n| n.ssid == selected_network.ssid)
202            {
203                self.set_selected_index(new_index);
204            } else {
205                self.set_selected_index(0);
206            }
207        }
208        self.selected_network = None;
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use std::time::Instant;
215
216    use super::{App, AppState};
217    use crate::wifi::{WifiNetwork, WifiSecurity};
218
219    fn network(
220        ssid: &str,
221        security: WifiSecurity,
222        connected: bool,
223    ) -> WifiNetwork {
224        WifiNetwork {
225            ssid: ssid.to_string(),
226            signal_strength: 80,
227            security,
228            frequency: 5180,
229            connected,
230        }
231    }
232
233    fn connected_network(ssid: &str) -> WifiNetwork {
234        network(ssid, WifiSecurity::WpaPsk, true)
235    }
236
237    #[test]
238    fn next_wraps_and_keeps_selection_state_in_sync() {
239        let mut app = App::new();
240        app.networks =
241            vec![connected_network("home"), connected_network("guest")];
242        app.selected_index = 1;
243
244        app.next();
245
246        assert_eq!(app.selected_index, 0);
247    }
248
249    #[test]
250    fn previous_wraps_and_keeps_selection_state_in_sync() {
251        let mut app = App::new();
252        app.networks =
253            vec![connected_network("home"), connected_network("guest")];
254        app.selected_index = 0;
255
256        app.previous();
257
258        assert_eq!(app.selected_index, 1);
259    }
260
261    #[test]
262    fn selecting_a_connected_network_starts_disconnect_timing() {
263        let mut app = App::new();
264        app.state = AppState::NetworkList;
265        app.networks = vec![connected_network("home")];
266
267        app.activate_selected_network();
268
269        assert!(matches!(app.state, AppState::Disconnecting));
270        assert!(app.connection_start_time.is_some());
271    }
272
273    #[test]
274    fn activate_selected_network_uses_current_selection_not_just_index_zero() {
275        let mut app = App::new();
276        app.state = AppState::NetworkList;
277        app.networks = vec![
278            network("cafe", WifiSecurity::Open, false),
279            network("office", WifiSecurity::WpaPsk, false),
280        ];
281        app.selected_index = 1;
282
283        app.activate_selected_network();
284
285        assert!(matches!(app.state, AppState::PasswordInput));
286        assert_eq!(
287            app.selected_network
288                .as_ref()
289                .map(|network| network.ssid.as_str()),
290            Some("office")
291        );
292    }
293
294    #[test]
295    fn starting_a_scan_clears_stale_scan_metadata() {
296        let mut app = App::new();
297        app.state = AppState::NetworkList;
298        app.networks = vec![connected_network("home")];
299        app.network_count = 3;
300        app.last_scan_time = Some(Instant::now());
301        app.selected_index = 0;
302
303        app.start_scan();
304
305        assert!(matches!(app.state, AppState::Scanning));
306        assert!(app.networks.is_empty());
307        assert_eq!(app.network_count, 0);
308        assert!(app.last_scan_time.is_none());
309        assert_eq!(app.selected_index, 0);
310    }
311
312    #[test]
313    fn start_scan_resets_selection_fields_together() {
314        let mut app = App::new();
315        app.networks =
316            vec![connected_network("home"), connected_network("guest")];
317        app.selected_index = 1;
318
319        app.start_scan();
320
321        assert_eq!(app.selected_index, 0);
322    }
323
324    #[test]
325    fn update_selection_after_rescan_restores_matching_ssid() {
326        let mut app = App::new();
327        app.networks =
328            vec![connected_network("guest"), connected_network("home")];
329        app.selected_network = Some(connected_network("home"));
330
331        app.update_selection_after_rescan();
332
333        assert_eq!(app.selected_index, 1);
334        assert!(app.selected_network.is_none());
335    }
336
337    #[test]
338    fn update_selection_after_rescan_resets_to_first_when_selected_ssid_disappears()
339     {
340        let mut app = App::new();
341        app.selected_index = 1;
342        app.networks =
343            vec![connected_network("guest"), connected_network("cafe")];
344        app.selected_network = Some(connected_network("home"));
345
346        app.update_selection_after_rescan();
347
348        assert_eq!(app.selected_index, 0);
349        assert!(app.selected_network.is_none());
350    }
351
352    #[test]
353    fn scan_failures_keep_the_app_running_with_a_retry_message() {
354        let mut app = App::new();
355        app.state = AppState::Scanning;
356
357        app.handle_scan_error("dbus unavailable");
358
359        assert!(matches!(app.state, AppState::NetworkList));
360        assert_eq!(
361            app.status_message,
362            "Scan failed: dbus unavailable. Press r to retry."
363        );
364    }
365}