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 }