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}