git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/pages/network.rs (14.1K)

  1 use crate::app::{AppAction, PageContent};
  2 use cce_ui::widget::ScrollRegion;
  3 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
  4 use cce_ui::widget::{Adapted, Toggle};
  5 
  6 #[derive(Debug, Clone)]
  7 pub struct WifiNetwork {
  8     pub ssid: String,
  9     pub signal: u8,
 10     pub secured: bool,
 11     pub in_use: bool,
 12 }
 13 
 14 #[derive(Debug, Clone)]
 15 pub struct NetworkState {
 16     pub loaded: bool,
 17     pub wifi_enabled: bool,
 18     pub connected_ssid: String,
 19     pub signal_strength: u8,
 20     pub ip_address: String,
 21     pub device: String,
 22     pub available: Vec<WifiNetwork>,
 23     pub wifi_list: ScrollRegion,
 24     pub wifi_toggle: Adapted<Toggle>,
 25 }
 26 
 27 impl Default for NetworkState {
 28     fn default() -> Self {
 29         Self {
 30             loaded: false,
 31             wifi_enabled: false,
 32             connected_ssid: String::new(),
 33             signal_strength: 0,
 34             ip_address: String::new(),
 35             device: String::new(),
 36             available: Vec::new(),
 37             wifi_list: ScrollRegion::new(26.0, 4.0),
 38             wifi_toggle: Toggle::new(),
 39         }
 40     }
 41 }
 42 
 43 #[derive(Debug, Clone)]
 44 pub enum NetworkMessage {
 45     Refreshed(NetworkState),
 46     ToggleWifi,
 47     ConnectWifi(String),
 48 }
 49 
 50 pub async fn fetch_network_state() -> NetworkState {
 51     let wifi_enabled = tokio::process::Command::new("nmcli")
 52         .args(["radio", "wifi"]).output().await.ok()
 53         .map(|o| String::from_utf8_lossy(&o.stdout).trim().starts_with("enabled"))
 54         .unwrap_or(false);
 55 
 56     let active = tokio::process::Command::new("nmcli")
 57         .args(["-t", "-f", "NAME,DEVICE,TYPE", "con", "show", "--active"])
 58         .output().await.ok()
 59         .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
 60         .unwrap_or_default();
 61 
 62     let (connected_ssid, device) = active.lines()
 63         .filter_map(|l| {
 64             let parts: Vec<&str> = l.split(':').collect();
 65             if parts.len() >= 3 && parts[parts.len() - 1] == "802-11-wireless" {
 66                 let ssid = parts[..parts.len() - 2].join(":");
 67                 let ssid_unescaped = ssid.replace("\\:", ":");
 68                 let device = parts[parts.len() - 2].to_string();
 69                 Some((ssid_unescaped, device))
 70             } else { None }
 71         }).next().unwrap_or_default();
 72 
 73     let signal = tokio::process::Command::new("nmcli")
 74         .args(["-t", "-f", "ACTIVE,SIGNAL", "dev", "wifi", "list"])
 75         .output().await.ok()
 76         .and_then(|o| {
 77             String::from_utf8_lossy(&o.stdout).lines()
 78                 .find(|l| l.starts_with("yes:"))
 79                 .and_then(|l| l.split(':').nth(1))
 80                 .and_then(|v| v.parse::<u8>().ok())
 81         }).unwrap_or(0);
 82 
 83     let ip_address = tokio::process::Command::new("nmcli")
 84         .args(["-t", "-f", "IP4.ADDRESS", "dev", "show", &device])
 85         .output().await.ok()
 86         .and_then(|o| {
 87             String::from_utf8_lossy(&o.stdout).lines()
 88                 .find(|l| !l.is_empty())
 89                 .map(|l| {
 90                     let val = l.split(':').nth(1).unwrap_or(l);
 91                     val.split('/').next().unwrap_or(val).to_string()
 92                 })
 93         }).unwrap_or_default();
 94 
 95     let available = if wifi_enabled { fetch_wifi_list().await } else { Vec::new() };
 96 
 97     NetworkState {
 98         loaded: true,
 99         wifi_enabled, connected_ssid, signal_strength: signal,
100         ip_address, device, available,
101         wifi_list: ScrollRegion::new(26.0, 4.0),
102         wifi_toggle: Toggle::new(),
103     }
104 }
105 
106 async fn fetch_wifi_list() -> Vec<WifiNetwork> {
107     let out = match tokio::process::Command::new("nmcli")
108         .args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"])
109         .output().await
110     {
111         Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
112         Err(_) => return Vec::new(),
113     };
114 
115     let mut networks = Vec::new();
116     let mut seen = std::collections::HashSet::new();
117     for line in out.lines() {
118         let parts: Vec<&str> = line.splitn(4, ':').collect();
119         if parts.len() >= 3 {
120             let ssid = parts[0].to_string();
121             if ssid.is_empty() || ssid == "--" || seen.contains(&ssid) { continue; }
122             seen.insert(ssid.clone());
123             networks.push(WifiNetwork {
124                 ssid, signal: parts[1].parse::<u8>().unwrap_or(0),
125                 secured: !parts[2].is_empty(),
126                 in_use: parts.len() > 3 && parts[3] == "*",
127             });
128         }
129     }
130     networks.sort_by(|a, b| b.signal.cmp(&a.signal));
131     networks
132 }
133 
134 fn wifi_connect(ssid: &str) {
135     let _ = tokio::process::Command::new("nmcli")
136         .args(["dev", "wifi", "connect", ssid]).spawn();
137 }
138 
139 fn wifi_toggle(enable: bool) {
140     let _ = tokio::process::Command::new("nmcli")
141         .args(["radio", "wifi", if enable { "on" } else { "off" }]).spawn();
142 }
143 
144 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
145 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
146 const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
147 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
148 const NET_BTN: [f32; 4] = [0.13, 0.20, 0.27, 1.0];
149 const ACT_BTN: [f32; 4] = [0.16, 0.29, 0.18, 1.0];
150 
151 pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
152     let mut final_pc = PageContent::new();
153     let sec_w = 320.0f32;
154     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
155 
156     // ── WiFi (label-less well) ──
157     builder.add_section_spanned(&mut final_pc, "", 1, root_focused, |sec| {
158         let sec_w = sec.cw;
159         let rx = sec.left;
160         let padding = sec.padding();
161         let row_gap = cce_ui::layout::label_margin();
162         let margin = padding.max(12.0);
163 
164         if !state.loaded {
165             sec.text("Loading WiFi interfaces...", margin, 0.0, 12.0, TEXT_DIM);
166         } else {
167             let wifi_btn_w = if sec_w < 200.0 { 40.0 } else { 60.0 };
168 
169             state.wifi_toggle.set_toggled(state.wifi_enabled);
170             state.wifi_toggle.set_label(if state.wifi_enabled { "ON" } else { "OFF" });
171             // Hand-placed at its real width — sec.widget grid-places at full
172             // column width (the old wide "ON" plate).
173             let yt = sec.ay();
174             let tx = sec.ax(margin);
175             cce_ui::layout::render_widget(sec.pc, &mut state.wifi_toggle, tx, yt, wifi_btn_w, 28.0, ctx);
176             sec.content_y = yt + 28.0 + row_gap;
177 
178             if state.wifi_enabled {
179                 // Flowing text rows — sec.text advances content_y itself.
180                 if !state.connected_ssid.is_empty() {
181                     let ssid_max_chars = ((sec_w - 2.0 * margin) / 7.0) as usize;
182                     let ssid_truncated = if state.connected_ssid.len() > ssid_max_chars {
183                         format!("{}...", &state.connected_ssid[..ssid_max_chars.saturating_sub(3)])
184                     } else {
185                         state.connected_ssid.clone()
186                     };
187                     sec.text(&format!("Connected: {}", ssid_truncated), margin, 0.0, 13.0, ACCENT);
188 
189                     if sec_w < 220.0 {
190                         sec.text(&format!("Signal: {}%", state.signal_strength), margin, 0.0, 12.0, TEXT_DIM);
191                     } else {
192                         sec.text(&format!("Signal: {}%  IP: {}", state.signal_strength, state.ip_address),
193                             margin, 0.0, 12.0, TEXT_DIM);
194                     }
195                 } else {
196                     sec.text("Not connected", margin, 0.0, 12.0, TEXT_DIM);
197                 }
198                 sec.content_y += row_gap;
199             }
200 
201             if state.wifi_enabled && !state.available.is_empty() {
202                 let list_box_x = rx + margin;
203                 let list_box_y = sec.ay();
204                 let list_box_w = sec_w - 2.0 * margin;
205                 let list_box_h = 160.0;
206 
207                 // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
208                 state.wifi_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
209                 state.wifi_list.update_bounds(state.available.len(), list_box_y, list_box_h);
210                 state.wifi_list.push_prims(sec.pc);
211 
212                 let btn_w = list_box_w - 2.0 * margin;
213                 let max_chars = ((btn_w / 6.5) as usize).saturating_sub(10).max(5);
214 
215                 sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
216                 for (idx, net) in state.available.iter().enumerate() {
217                     if let Some(draw_y) = state.wifi_list.get_item_draw_y(idx, 4.0) {
218                         let prefix = if net.in_use { ">" } else { " " };
219                         let ssid_truncated = if net.ssid.len() > max_chars {
220                             format!("{}...", &net.ssid[..max_chars.saturating_sub(3)])
221                         } else {
222                             net.ssid.clone()
223                         };
224                         let label = format!("{}  {}  ({}%)", prefix, ssid_truncated, net.signal);
225                         let active = net.in_use;
226                         sec.pc.button(&label, list_box_x + margin, draw_y, btn_w, 26.0,
227                             if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
228                             if active { ACCENT } else { TEXT_FG },
229                             AppAction::Network(NetworkMessage::ConnectWifi(net.ssid.clone())));
230                     }
231                 }
232                 sec.pc.pop_clip_rect();
233                 sec.content_y += list_box_h + row_gap;
234             }
235         }
236     });
237 
238     final_pc
239 }
240 
241 pub fn update(state: &mut NetworkState, msg: NetworkMessage) {
242     match msg {
243         NetworkMessage::Refreshed(new) => {
244             state.loaded = new.loaded;
245             state.wifi_enabled = new.wifi_enabled;
246             state.connected_ssid = new.connected_ssid;
247             state.signal_strength = new.signal_strength;
248             state.ip_address = new.ip_address;
249             state.device = new.device;
250             state.available = new.available;
251         }
252         NetworkMessage::ToggleWifi => {
253             state.wifi_enabled = !state.wifi_enabled;
254             wifi_toggle(state.wifi_enabled);
255         }
256         NetworkMessage::ConnectWifi(ssid) => { wifi_connect(&ssid); }
257     }
258 }
259 
260 impl NetworkState {
261     /// The wifi list is only laid out (and its rect refreshed) when this holds — gate the
262     /// dissolved region's input on it so a stale rect can't eat events.
263     fn wifi_list_visible(&self) -> bool {
264         self.loaded && self.wifi_enabled && !self.available.is_empty()
265     }
266 }
267 
268 impl crate::pages::AppPage for NetworkState {
269     // Sections: [WiFi]
270     // Mirrors the view's load gate (d13a901): `wifi_toggle` is painted only in
271     // the `else` of `if !state.loaded`, so reporting it while the page still
272     // reads "Loading WiFi interfaces..." is a dead root.
273     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
274         if !self.loaded {
275             return vec![Vec::new()];
276         }
277         vec![vec![self.wifi_toggle.id()]]
278     }
279 
280     fn view(
281         &mut self,
282         cx: f32,
283         cy: f32,
284         cw: f32,
285         ch: f32,
286         root_focused: bool,
287         sec_focused: &[bool],
288         layout: &mut dyn LayoutStrategy,
289         ctx: &mut cce_ui::context::UiContext,
290     ) -> crate::app::PageContent {
291         // Page root dissolved (6u): the ctrl-nav entry focuses section 0 now, which used to
292         // be expressed as root focus here.
293         let focused = root_focused || sec_focused.first().copied().unwrap_or(false);
294         view(self, cx, cy, cw, ch, focused, layout, ctx)
295     }
296 
297     fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
298         if self.wifi_toggle.take_change() {
299             actions.push(crate::app::AppAction::Network(NetworkMessage::ToggleWifi));
300         }
301     }
302 
303     fn handle_pointer_move(
304         &mut self,
305         lx: f32,
306         ly: f32,
307         _actions: &mut Vec<crate::app::AppAction>,
308         _ctx: &mut cce_ui::context::UiContext,
309     ) -> bool {
310         self.wifi_list_visible() && self.wifi_list.cursor_moved(lx, ly)
311     }
312 
313     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
314         self.wifi_list_visible() && self.wifi_list.press(lx, ly)
315     }
316 
317     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
318         self.wifi_list.release()
319     }
320 
321     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
322         self.wifi_list_visible() && self.wifi_list.wheel(delta, lx, ly)
323     }
324 
325     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
326         self.wifi_list_visible() && self.wifi_list.keyboard(event)
327     }
328 
329     fn tick(&mut self, dt: f32) -> bool {
330         self.wifi_list.tick(dt)
331     }
332 }
333 
334 #[cfg(test)]
335 mod tests {
336     use super::*;
337 
338     #[test]
339     fn test_view_layout_grid() {
340         let mut state = NetworkState::default();
341         state.loaded = true;
342         state.wifi_enabled = true;
343         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
344         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut cce_ui::context::UiContext::new());
345         assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
346     }
347 
348     #[test]
349     fn test_view_layout_connected() {
350         let mut state = NetworkState::default();
351         state.loaded = true;
352         state.wifi_enabled = true;
353         state.connected_ssid = "MyHomeWiFi".to_string();
354         state.signal_strength = 80;
355         state.ip_address = "192.168.1.50".to_string();
356         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
357         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut cce_ui::context::UiContext::new());
358         assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
359     }
360 
361     #[test]
362     fn section_widgets_mirror_load_gate() {
363         use crate::pages::AppPage;
364         let mut st = NetworkState::default();
365         // Not loaded: the view paints only "Loading WiFi interfaces...", so
366         // reporting the toggle would be a root nothing registered this frame.
367         assert_eq!(st.section_widgets(), vec![Vec::new()]);
368         st.loaded = true;
369         assert_eq!(st.section_widgets()[0].len(), 1);
370     }
371 }