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