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 }