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

src/pages/network.rs (15.7K)

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