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

src/pages/bluetooth.rs (13K)

  1 use crate::app::{form_button, AppAction, PageContent};
  2 use cce_ui::context::UiContext;
  3 use cce_ui::widget::Handle;
  4 use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
  5 use cce_ui::widget::{Adapted, Toggle};
  6 
  7 #[derive(Debug, Clone)]
  8 pub struct BluetoothDevice {
  9     pub mac: String,
 10     pub name: String,
 11     pub icon: String,
 12     pub connected: bool,
 13 }
 14 
 15 #[derive(Debug, Clone)]
 16 pub struct BluetoothState {
 17     pub loaded: bool,
 18     pub installed: bool,
 19     pub service_active: bool,
 20     pub enabled: bool,
 21     pub devices: Vec<BluetoothDevice>,
 22     pub scanning: bool,
 23     pub toggle: Handle<Adapted<Toggle>>,
 24 }
 25 
 26 impl Default for BluetoothState {
 27     fn default() -> Self {
 28         Self {
 29             loaded: false,
 30             installed: false,
 31             service_active: false,
 32             enabled: false,
 33             devices: Vec::new(),
 34             scanning: false,
 35             toggle: Handle::none(),
 36         }
 37     }
 38 }
 39 
 40 impl BluetoothState {
 41     /// The page's state, its widgets inserted into `ctx`.
 42     pub fn new(ctx: &mut UiContext) -> Self {
 43         Self { toggle: ctx.insert(Toggle::new()), ..Self::default() }
 44     }
 45 }
 46 
 47 #[derive(Debug, Clone)]
 48 pub enum BluetoothMessage {
 49     Refreshed(BluetoothState),
 50     Toggle,
 51     Connect(String),
 52     Disconnect(String),
 53     Scan,
 54     InstallTools,
 55     StartService,
 56 }
 57 
 58 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 59 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 60 const ACCENT: [f32; 4] = [0.35, 0.65, 0.90, 1.0];
 61 const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 62 const TOGGLE_ON: [f32; 4] = [0.13, 0.18, 0.14, 1.0];
 63 const TOGGLE_OFF: [f32; 4] = [0.15, 0.15, 0.20, 1.0];
 64 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
 65 
 66 pub async fn fetch_bluetooth_page_state() -> BluetoothState {
 67     let installed = tokio::process::Command::new("bluetoothctl")
 68         .arg("--version")
 69         .output()
 70         .await
 71         .is_ok();
 72     if !installed {
 73         return BluetoothState { loaded: true, ..Default::default() };
 74     }
 75 
 76     let service_active = tokio::process::Command::new("systemctl")
 77         .args(["is-active", "bluetooth"])
 78         .output()
 79         .await
 80         .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
 81         .unwrap_or(false);
 82     if !service_active {
 83         return BluetoothState { loaded: true, installed, ..Default::default() };
 84     }
 85 
 86     let enabled = tokio::process::Command::new("bluetoothctl")
 87         .args(["show"]).output().await.ok()
 88         .map(|o| String::from_utf8_lossy(&o.stdout).lines().any(|l| l.contains("Powered: yes")))
 89         .unwrap_or(false);
 90 
 91     let devices = if enabled { fetch_devices().await } else { Vec::new() };
 92     BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Handle::none() }
 93 }
 94 
 95 async fn fetch_devices() -> Vec<BluetoothDevice> {
 96     let out = match tokio::process::Command::new("bluetoothctl")
 97         .args(["devices"]).output().await
 98     {
 99         Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
100         Err(_) => return Vec::new(),
101     };
102 
103     let mut devices = Vec::new();
104     for line in out.lines() {
105         let rest = line.strip_prefix("Device ").unwrap_or("");
106         let parts: Vec<&str> = rest.splitn(2, ' ').collect();
107         if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() { continue; }
108         let mac = parts[0].to_string();
109         let default_name = parts[1].to_string();
110 
111         let info = tokio::process::Command::new("bluetoothctl")
112             .args(["info", &mac]).output().await.ok()
113             .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
114             .unwrap_or_default();
115 
116         let info_name = info.lines()
117             .find(|l| l.contains("Name:"))
118             .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
119 
120         let info_alias = info.lines()
121             .find(|l| l.contains("Alias:"))
122             .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
123 
124         let name = info_name.or(info_alias).unwrap_or(default_name);
125 
126         let connected = info.lines().any(|l| l.contains("Connected: yes"));
127         let icon = info.lines()
128             .find(|l| l.contains("Icon:"))
129             .and_then(|l| l.split(':').nth(1).map(|s| s.trim().to_string()))
130             .unwrap_or_else(|| "audio-card".into());
131 
132         devices.push(BluetoothDevice { mac, name, icon, connected });
133     }
134     devices
135 }
136 
137 fn bt_toggle(enable: bool) {
138     let _ = tokio::process::Command::new("bluetoothctl")
139         .args(["power", if enable { "on" } else { "off" }])
140         .spawn();
141 }
142 
143 fn bt_connect(mac: &str) {
144     let _ = tokio::process::Command::new("bluetoothctl")
145         .args(["connect", mac])
146         .spawn();
147 }
148 
149 fn bt_disconnect(mac: &str) {
150     let _ = tokio::process::Command::new("bluetoothctl")
151         .args(["disconnect", mac])
152         .spawn();
153 }
154 
155 fn bt_scan() {
156     let _ = tokio::process::Command::new("bluetoothctl")
157         .args(["scan", "on"])
158         .spawn();
159     let _ = tokio::process::Command::new("sh")
160         .args(["-c", "sleep 5 && bluetoothctl scan off"])
161         .spawn();
162 }
163 
164 pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
165     let mut final_pc = PageContent::new();
166     let sec_w = 320.0f32;
167     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
168 
169     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
170         let bt_sec_w = sec.cw;
171         let font_size = 12.0;
172         let mut form = sec.form();
173         let mut col = form.column();
174 
175         if !state.loaded {
176             col.text("Loading Bluetooth status...", font_size, TEXT_DIM);
177         } else if !state.installed || !state.service_active {
178             let (note, label, msg) = if !state.installed {
179                 ("Bluetooth tools (bluez) not installed", "Install Tools", BluetoothMessage::InstallTools)
180             } else {
181                 ("Bluetooth service is stopped", "Start Service", BluetoothMessage::StartService)
182             };
183             col.text(note, font_size, TEXT_DIM);
184             let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
185             col.row(|r| form_button(r, label, btn_w, (TOGGLE_ON, BTN_HOVER, WHITE), AppAction::Bluetooth(msg)));
186         } else {
187             let bt_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 60.0 };
188             let scan_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 52.0 };
189             ctx[state.toggle].set_toggled(state.enabled);
190             ctx[state.toggle].set_label(if state.enabled { "ON" } else { "OFF" });
191             col.row(|r| {
192                 r.widget_w_h(ctx, state.toggle, bt_btn_w, cce_ui::layout::toggle_height());
193                 form_button(r, "Scan", scan_btn_w, (TOGGLE_OFF, BTN_HOVER, WHITE), AppAction::Bluetooth(BluetoothMessage::Scan));
194             });
195 
196             if state.devices.is_empty() {
197                 if state.enabled {
198                     let no_devices_msg = if bt_sec_w < 200.0 { "No paired devices" } else { "No paired devices found" };
199                     col.text(no_devices_msg, font_size, TEXT_DIM);
200                 }
201             } else {
202                 for dev in &state.devices {
203                     let btn_w = if bt_sec_w < 250.0 { 42.0 } else { 70.0 };
204                     let action_label = match (dev.connected, bt_sec_w < 250.0) {
205                         (true, true) => "Disc",
206                         (true, false) => "Disconnect",
207                         (false, true) => "Conn",
208                         (false, false) => "Connect",
209                     };
210                     let is_unknown = dev.name.replace('-', ":").eq_ignore_ascii_case(&dev.mac);
211                     let label = match (is_unknown, bt_sec_w < 350.0) {
212                         (true, true) => dev.mac.clone(),
213                         (true, false) => format!("Unknown Device ({})", dev.mac),
214                         (false, true) => dev.name.clone(),
215                         (false, false) => format!("{} ({})", dev.name, dev.mac),
216                     };
217                     let (face, action) = if dev.connected {
218                         (TOGGLE_OFF, AppAction::Bluetooth(BluetoothMessage::Disconnect(dev.mac.clone())))
219                     } else {
220                         (TOGGLE_ON, AppAction::Bluetooth(BluetoothMessage::Connect(dev.mac.clone())))
221                     };
222                     let connected = dev.connected;
223                     col.row(|r| {
224                         form_button(r, action_label, btn_w, (face, BTN_HOVER, WHITE), action);
225                         // A connected device wears a `check` glyph ahead of its name (it was
226                         // a ">" in the label); every row keeps the glyph's room so the names
227                         // line up either way. The name is cut where the row ends.
228                         let line_h = cce_ui::layout::form_line_height(font_size);
229                         r.draw(0.0, line_h, true, move |pc, cell, _| {
230                             let g = font_size;
231                             if connected {
232                                 pc.icon("check", cell.x, cell.y + (line_h - g) / 2.0, g, g, ACCENT);
233                             }
234                             let tx = cell.x + g + cce_ui::layout::CONTROL_TEXT_INSET;
235                             let color = if connected { ACCENT } else { TEXT_FG };
236                             pc.text_with_bounds(&label, tx, cell.y, font_size, color,
237                                 Some([tx, cell.y - font_size, cell.x + cell.width, cell.y + 2.0 * font_size]));
238                         });
239                     });
240                 }
241             }
242         }
243         sec.place(form, ctx);
244     });
245 
246     final_pc
247 }
248 
249 pub fn update(state: &mut BluetoothState, msg: BluetoothMessage, _ctx: &mut UiContext) {
250     match msg {
251         BluetoothMessage::Refreshed(new) => {
252             state.loaded = new.loaded;
253             state.installed = new.installed;
254             state.service_active = new.service_active;
255             state.enabled = new.enabled;
256             state.devices = new.devices;
257             state.scanning = new.scanning;
258         }
259         BluetoothMessage::Toggle => {
260             state.enabled = !state.enabled;
261             bt_toggle(state.enabled);
262         }
263         BluetoothMessage::Connect(mac) => { bt_connect(&mac); }
264         BluetoothMessage::Disconnect(mac) => { bt_disconnect(&mac); }
265         BluetoothMessage::Scan => { bt_scan(); }
266         BluetoothMessage::InstallTools => {
267             let _ = tokio::process::Command::new("pkexec")
268                 .args(["sh", "-c", "pacman -S --noconfirm bluez bluez-utils && systemctl enable --now bluetooth"])
269                 .spawn();
270         }
271         BluetoothMessage::StartService => {
272             let _ = tokio::process::Command::new("pkexec")
273                 .args(["systemctl", "enable", "--now", "bluetooth"])
274                 .spawn();
275         }
276     }
277 }
278 
279 impl crate::pages::AppPage for BluetoothState {
280     // Sections: [Bluetooth]
281     // The gate mirrors view()'s branch chain exactly (the d13a901 lesson):
282     // `toggle` is painted only in the innermost `else`, so reporting it from any
283     // earlier branch is a dead root. Unlike a load gate this one does not close
284     // on its own — `!installed` and `!service_active` are steady states, so on a
285     // host without bluez the page's only ctrl-nav target stays dead for the life
286     // of the process and every pointer move over the page drops an event.
287     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
288         if !self.loaded || !self.installed || !self.service_active {
289             return vec![Vec::new()];
290         }
291         vec![vec![self.toggle.id()]]
292     }
293 
294     fn view(
295         &mut self,
296         cx: f32,
297         cy: f32,
298         cw: f32,
299         ch: f32,
300         _root_focused: bool,
301         sec_focused: &[bool],
302         layout: &mut PageFlow,
303         ctx: &mut cce_ui::context::UiContext,
304     ) -> crate::app::PageContent {
305         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
306     }
307 
308     fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
309         if ctx[self.toggle].take_change() {
310             actions.push(crate::app::AppAction::Bluetooth(BluetoothMessage::Toggle));
311         }
312     }
313 }
314 
315 #[cfg(test)]
316 mod tests {
317     use super::*;
318     use crate::pages::AppPage;
319 
320     #[test]
321     fn section_widgets_mirror_branch_chain() {
322         let mut ui = cce_ui::context::UiContext::new();
323         let mut st = BluetoothState::new(&mut ui);
324         // Each of the three early branches paints a message (and maybe a plain
325         // PageContent button) but never `toggle` — the widget lives only in the
326         // innermost `else`. Unlike a load gate, the middle two are steady
327         // states: a host without bluez sits in one of them forever.
328         assert_eq!(st.section_widgets(), vec![Vec::new()], "not loaded");
329         st.loaded = true;
330         assert_eq!(st.section_widgets(), vec![Vec::new()], "bluez absent");
331         st.installed = true;
332         assert_eq!(st.section_widgets(), vec![Vec::new()], "service stopped");
333         st.service_active = true;
334         assert_eq!(st.section_widgets()[0].len(), 1, "toggle painted");
335     }
336 }