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

commitc699ade16f849aae6f3f3c20572ecfedc899bcb3
parent82061ce507
authorLucas Galante <[email protected]>
date2026-08-11 12:06
feat: split the Radios page into Network and Bluetooth pages

pages/bluetooth.rs owns the bluetoothctl state/fetch/actions and its
own label-less well (extracted verbatim from network.rs, messages
de-prefixed); network.rs keeps WiFi only, as a label-less well.
Page::Radios becomes Page::Network + Page::Bluetooth (each with its
own gated watcher), AppAction::Radios splits likewise. Two old layout
bugs fixed on the way: the WiFi status lines double-advanced content_y
(the SSID list overlapped the Signal/IP line), and the hand-placed
Scan button overlapped the grid-placed toggle — both toggles are now
hand-placed at their real width.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs             |  12 +-
 src/main.rs            |  13 +-
 src/pages/audio.rs     |   4 -
 src/pages/bluetooth.rs | 323 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/pages/mod.rs       |  12 +-
 src/pages/network.rs   | 305 +++-------------------------------------------
 src/watchers.rs        |   7 +-
 7 files changed, 376 insertions(+), 300 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 2f7c7c2..b2437e6 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,6 +1,7 @@
 use cce_ui::layout::RenderTarget;
 
 use crate::pages::audio;
+use crate::pages::bluetooth;
 use crate::pages::default_apps;
 use crate::pages::network;
 use crate::pages::processes;
@@ -16,6 +17,7 @@ use crate::pages::Page;
 pub struct AppState {
     pub current_page: Page,
     pub audio: audio::AudioState,
+    pub bluetooth: bluetooth::BluetoothState,
     pub default_apps: default_apps::DefaultAppsState,
     pub network: network::NetworkState,
     pub processes: processes::ProcessesState,
@@ -33,6 +35,7 @@ impl Default for AppState {
         Self {
             current_page: Page::ALL[0],
             audio: audio::AudioState::default(),
+            bluetooth: bluetooth::BluetoothState::default(),
             default_apps: default_apps::DefaultAppsState::default(),
             network: network::NetworkState::default(),
             processes: processes::ProcessesState::default(),
@@ -52,11 +55,12 @@ impl AppState {
         match page {
             Page::Accounts => &self.accounts,
             Page::Audio => &self.audio,
+            Page::Bluetooth => &self.bluetooth,
             Page::DefaultApps => &self.default_apps,
             Page::Packages => &self.packages,
             Page::Processes => &self.processes,
             Page::Services => &self.services,
-            Page::Radios => &self.network,
+            Page::Network => &self.network,
             Page::Storage => &self.storage,
             Page::System => &self.system_info,
             Page::Fonts => &self.fonts,
@@ -68,11 +72,12 @@ impl AppState {
         match page {
             Page::Accounts => &mut self.accounts,
             Page::Audio => &mut self.audio,
+            Page::Bluetooth => &mut self.bluetooth,
             Page::DefaultApps => &mut self.default_apps,
             Page::Packages => &mut self.packages,
             Page::Processes => &mut self.processes,
             Page::Services => &mut self.services,
-            Page::Radios => &mut self.network,
+            Page::Network => &mut self.network,
             Page::Storage => &mut self.storage,
             Page::System => &mut self.system_info,
             Page::Fonts => &mut self.fonts,
@@ -94,7 +99,8 @@ pub enum AppAction {
     Exit,
     Audio(audio::AudioMessage),
     DefaultApps(default_apps::DefaultAppsMessage),
-    Radios(network::NetworkMessage),
+    Network(network::NetworkMessage),
+    Bluetooth(bluetooth::BluetoothMessage),
     Processes(processes::ProcessesMessage),
     Services(services::ServicesMessage),
     SystemInfo(system_info::SystemMessage),
diff --git a/src/main.rs b/src/main.rs
index d6fad2c..1d78923 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -55,6 +55,7 @@ struct SystemInterface {
 
     rx_audio: std::sync::mpsc::Receiver<pages::audio::AudioState>,
     rx_network: std::sync::mpsc::Receiver<pages::network::NetworkState>,
+    rx_bluetooth: std::sync::mpsc::Receiver<pages::bluetooth::BluetoothState>,
     pub rx_processes: std::sync::mpsc::Receiver<pages::processes::ProcessesState>,
     rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemInfo>,
     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
@@ -154,6 +155,7 @@ impl cce_ui::engine::Application for SystemInterface {
             cursor_y: 0.0,
             rx_audio: watchers.rx_audio,
             rx_network: watchers.rx_network,
+            rx_bluetooth: watchers.rx_bluetooth,
             rx_processes: watchers.rx_processes,
             rx_default_apps: watchers.rx_default_apps,
             rx_system: watchers.rx_system,
@@ -488,7 +490,13 @@ impl SystemInterface {
         }
         while let Ok(s) = self.rx_network.try_recv() {
             network::update(&mut self.app.network, network::NetworkMessage::Refreshed(s));
-            if self.app.current_page == Page::Radios {
+            if self.app.current_page == Page::Network {
+                self.needs_rebuild = true;
+            }
+        }
+        while let Ok(s) = self.rx_bluetooth.try_recv() {
+            pages::bluetooth::update(&mut self.app.bluetooth, pages::bluetooth::BluetoothMessage::Refreshed(s));
+            if self.app.current_page == Page::Bluetooth {
                 self.needs_rebuild = true;
             }
         }
@@ -569,7 +577,8 @@ impl SystemInterface {
         match action {
             AppAction::Exit => {}
             AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone()),
-            AppAction::Radios(m) => network::update(&mut self.app.network, m.clone()),
+            AppAction::Network(m) => network::update(&mut self.app.network, m.clone()),
+            AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone()),
             AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone(), &mut self.ui_context),
             AppAction::Processes(m) => processes::update(&mut self.app.processes, m.clone()),
             AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index 91b492d..4985cd5 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -210,14 +210,10 @@ async fn fetch_sources(connected_ports: &[String]) -> Vec<AudioSource> {
 #[allow(dead_code)]
 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
-const MUTED_BG: [f32; 4] = [0.33, 0.20, 0.20, 1.0];
-const BTN_INACTIVE: [f32; 4] = [0.13, 0.18, 0.14, 1.0];
-const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
 #[allow(dead_code)]
 const BLANK_BAR: [f32; 4] = [0.15, 0.15, 0.24, 1.0];
 #[allow(dead_code)]
 const FILL_BAR: [f32; 4] = [0.30, 0.50, 0.32, 1.0];
-const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 #[allow(dead_code)]
 const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
 
diff --git a/src/pages/bluetooth.rs b/src/pages/bluetooth.rs
new file mode 100644
index 0000000..2c47c86
--- /dev/null
+++ b/src/pages/bluetooth.rs
@@ -0,0 +1,323 @@
+use crate::app::{AppAction, PageContent, SectionContextExt};
+use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy};
+use cce_ui::widget::{Adapted, Toggle};
+
+#[derive(Debug, Clone)]
+pub struct BluetoothDevice {
+    pub mac: String,
+    pub name: String,
+    pub icon: String,
+    pub connected: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct BluetoothState {
+    pub loaded: bool,
+    pub installed: bool,
+    pub service_active: bool,
+    pub enabled: bool,
+    pub devices: Vec<BluetoothDevice>,
+    pub scanning: bool,
+    pub toggle: Adapted<Toggle>,
+}
+
+impl Default for BluetoothState {
+    fn default() -> Self {
+        Self {
+            loaded: false,
+            installed: false,
+            service_active: false,
+            enabled: false,
+            devices: Vec::new(),
+            scanning: false,
+            toggle: Toggle::new(),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum BluetoothMessage {
+    Refreshed(BluetoothState),
+    Toggle,
+    Connect(String),
+    Disconnect(String),
+    Scan,
+    InstallTools,
+    StartService,
+}
+
+const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
+const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+const ACCENT: [f32; 4] = [0.35, 0.65, 0.90, 1.0];
+const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+const TOGGLE_ON: [f32; 4] = [0.13, 0.18, 0.14, 1.0];
+const TOGGLE_OFF: [f32; 4] = [0.15, 0.15, 0.20, 1.0];
+const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
+
+pub async fn fetch_bluetooth_page_state() -> BluetoothState {
+    let installed = tokio::process::Command::new("bluetoothctl")
+        .arg("--version")
+        .output()
+        .await
+        .is_ok();
+    if !installed {
+        return BluetoothState { loaded: true, ..Default::default() };
+    }
+
+    let service_active = tokio::process::Command::new("systemctl")
+        .args(["is-active", "bluetooth"])
+        .output()
+        .await
+        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
+        .unwrap_or(false);
+    if !service_active {
+        return BluetoothState { loaded: true, installed, ..Default::default() };
+    }
+
+    let enabled = tokio::process::Command::new("bluetoothctl")
+        .args(["show"]).output().await.ok()
+        .map(|o| String::from_utf8_lossy(&o.stdout).lines().any(|l| l.contains("Powered: yes")))
+        .unwrap_or(false);
+
+    let devices = if enabled { fetch_devices().await } else { Vec::new() };
+    BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Toggle::new() }
+}
+
+async fn fetch_devices() -> Vec<BluetoothDevice> {
+    let out = match tokio::process::Command::new("bluetoothctl")
+        .args(["devices"]).output().await
+    {
+        Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
+        Err(_) => return Vec::new(),
+    };
+
+    let mut devices = Vec::new();
+    for line in out.lines() {
+        let rest = line.strip_prefix("Device ").unwrap_or("");
+        let parts: Vec<&str> = rest.splitn(2, ' ').collect();
+        if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() { continue; }
+        let mac = parts[0].to_string();
+        let default_name = parts[1].to_string();
+
+        let info = tokio::process::Command::new("bluetoothctl")
+            .args(["info", &mac]).output().await.ok()
+            .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
+            .unwrap_or_default();
+
+        let info_name = info.lines()
+            .find(|l| l.contains("Name:"))
+            .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
+
+        let info_alias = info.lines()
+            .find(|l| l.contains("Alias:"))
+            .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
+
+        let name = info_name.or(info_alias).unwrap_or(default_name);
+
+        let connected = info.lines().any(|l| l.contains("Connected: yes"));
+        let icon = info.lines()
+            .find(|l| l.contains("Icon:"))
+            .and_then(|l| l.split(':').nth(1).map(|s| s.trim().to_string()))
+            .unwrap_or_else(|| "audio-card".into());
+
+        devices.push(BluetoothDevice { mac, name, icon, connected });
+    }
+    devices
+}
+
+fn bt_toggle(enable: bool) {
+    let _ = tokio::process::Command::new("bluetoothctl")
+        .args(["power", if enable { "on" } else { "off" }])
+        .spawn();
+}
+
+fn bt_connect(mac: &str) {
+    let _ = tokio::process::Command::new("bluetoothctl")
+        .args(["connect", mac])
+        .spawn();
+}
+
+fn bt_disconnect(mac: &str) {
+    let _ = tokio::process::Command::new("bluetoothctl")
+        .args(["disconnect", mac])
+        .spawn();
+}
+
+fn bt_scan() {
+    let _ = tokio::process::Command::new("bluetoothctl")
+        .args(["scan", "on"])
+        .spawn();
+    let _ = tokio::process::Command::new("sh")
+        .args(["-c", "sleep 5 && bluetoothctl scan off"])
+        .spawn();
+}
+
+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 {
+    let mut final_pc = PageContent::new();
+    let sec_w = 320.0f32;
+    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
+
+    builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+        let bt_sec_w = sec.cw;
+        let padding = sec.padding();
+        let row_gap = cce_ui::layout::label_margin();
+        let margin = padding.max(12.0);
+        let font_size = 12.0;
+        let btn_h = 28.0;
+
+        if !state.loaded {
+            sec.text("Loading Bluetooth status...", margin, 0.0, font_size, TEXT_DIM);
+        } else if !state.installed {
+            sec.text("Bluetooth tools (bluez) not installed", margin, 0.0, font_size, TEXT_DIM);
+            let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
+            let yt = sec.ay();
+            sec.button("Install Tools", sec.ax(margin), yt, btn_w, btn_h,
+                TOGGLE_ON, BTN_HOVER, WHITE,
+                AppAction::Bluetooth(BluetoothMessage::InstallTools));
+            sec.content_y = yt + btn_h + row_gap;
+        } else if !state.service_active {
+            sec.text("Bluetooth service is stopped", margin, 0.0, font_size, TEXT_DIM);
+            let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
+            let yt = sec.ay();
+            sec.button("Start Service", sec.ax(margin), yt, btn_w, btn_h,
+                TOGGLE_ON, BTN_HOVER, WHITE,
+                AppAction::Bluetooth(BluetoothMessage::StartService));
+            sec.content_y = yt + btn_h + row_gap;
+        } else {
+            let yt = sec.ay();
+            let bt_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 60.0 };
+            let scan_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 52.0 };
+            let scan_btn_x = margin + bt_btn_w + row_gap;
+
+            state.toggle.set_toggled(state.enabled);
+            state.toggle.set_label(if state.enabled { "ON" } else { "OFF" });
+            // Hand-placed: sec.widget grid-places at full column width, which
+            // would sit the toggle under the Scan button.
+            let tx = sec.ax(margin);
+            render_widget(sec.pc, &mut state.toggle, tx, yt, bt_btn_w, btn_h, ctx);
+            sec.button("Scan", sec.ax(scan_btn_x), yt, scan_btn_w, btn_h,
+                TOGGLE_OFF, BTN_HOVER, WHITE,
+                AppAction::Bluetooth(BluetoothMessage::Scan));
+            sec.content_y = yt + btn_h + row_gap;
+
+            if state.devices.is_empty() {
+                if state.enabled {
+                    let no_devices_msg = if bt_sec_w < 200.0 { "No paired devices" } else { "No paired devices found" };
+                    sec.text(no_devices_msg, margin, 0.0, font_size, TEXT_DIM);
+                }
+            } else {
+                let item_h = 22.0;
+                for dev in &state.devices {
+                    let status = if dev.connected { ">" } else { " " };
+                    let btn_w = if bt_sec_w < 250.0 { 42.0 } else { 70.0 };
+                    let action_label = if dev.connected {
+                        if bt_sec_w < 250.0 { "Disc" } else { "Disconnect" }
+                    } else {
+                        if bt_sec_w < 250.0 { "Conn" } else { "Connect" }
+                    };
+
+                    let label_max_w = (bt_sec_w - btn_w - 2.0 * padding - margin - row_gap).max(20.0);
+                    let label_max_chars = ((label_max_w / 6.0) as usize).max(5);
+
+                    let is_unknown = dev.name.replace('-', ":").eq_ignore_ascii_case(&dev.mac);
+                    let label = if is_unknown {
+                        if bt_sec_w < 350.0 {
+                            format!("{} {}", status, dev.mac)
+                        } else {
+                            format!("{} Unknown Device ({})", status, dev.mac)
+                        }
+                    } else {
+                        if bt_sec_w < 350.0 {
+                            let name_truncated = if dev.name.len() > label_max_chars {
+                                format!("{}...", &dev.name[..label_max_chars.saturating_sub(3)])
+                            } else {
+                                dev.name.clone()
+                            };
+                            format!("{} {}", status, name_truncated)
+                        } else {
+                            let full_label = format!("{} {} ({})", status, dev.name, dev.mac);
+                            if full_label.len() > label_max_chars {
+                                format!("{}...", &full_label[..label_max_chars.saturating_sub(3)])
+                            } else {
+                                full_label
+                            }
+                        }
+                    };
+
+                    let yt = sec.ay();
+                    let btn_x = margin;
+                    let text_x = margin + btn_w + row_gap;
+                    let text_y_offset = (item_h - font_size) / 2.0;
+                    sec.button(action_label, sec.ax(btn_x), yt, btn_w, item_h,
+                        if dev.connected { TOGGLE_OFF } else { TOGGLE_ON }, BTN_HOVER, WHITE,
+                        if dev.connected {
+                            AppAction::Bluetooth(BluetoothMessage::Disconnect(dev.mac.clone()))
+                        } else {
+                            AppAction::Bluetooth(BluetoothMessage::Connect(dev.mac.clone()))
+                        });
+                    sec.text(&label, text_x, text_y_offset, font_size, if dev.connected { ACCENT } else { TEXT_FG });
+                    sec.content_y = yt + item_h + row_gap;
+                }
+            }
+        }
+    });
+
+    final_pc
+}
+
+pub fn update(state: &mut BluetoothState, msg: BluetoothMessage) {
+    match msg {
+        BluetoothMessage::Refreshed(new) => {
+            state.loaded = new.loaded;
+            state.installed = new.installed;
+            state.service_active = new.service_active;
+            state.enabled = new.enabled;
+            state.devices = new.devices;
+            state.scanning = new.scanning;
+        }
+        BluetoothMessage::Toggle => {
+            state.enabled = !state.enabled;
+            bt_toggle(state.enabled);
+        }
+        BluetoothMessage::Connect(mac) => { bt_connect(&mac); }
+        BluetoothMessage::Disconnect(mac) => { bt_disconnect(&mac); }
+        BluetoothMessage::Scan => { bt_scan(); }
+        BluetoothMessage::InstallTools => {
+            let _ = tokio::process::Command::new("pkexec")
+                .args(["sh", "-c", "pacman -S --noconfirm bluez bluez-utils && systemctl enable --now bluetooth"])
+                .spawn();
+        }
+        BluetoothMessage::StartService => {
+            let _ = tokio::process::Command::new("pkexec")
+                .args(["systemctl", "enable", "--now", "bluetooth"])
+                .spawn();
+        }
+    }
+}
+
+impl crate::pages::AppPage for BluetoothState {
+    // Sections: [Bluetooth]
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
+        vec![vec![self.toggle.id()]]
+    }
+
+    fn view(
+        &mut self,
+        cx: f32,
+        cy: f32,
+        cw: f32,
+        ch: f32,
+        _root_focused: bool,
+        sec_focused: &[bool],
+        layout: &mut dyn LayoutStrategy,
+        ctx: &mut cce_ui::context::UiContext,
+    ) -> crate::app::PageContent {
+        view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
+    }
+
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
+        if self.toggle.take_change() {
+            actions.push(crate::app::AppAction::Bluetooth(BluetoothMessage::Toggle));
+        }
+    }
+}
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index e3edcc8..a61095f 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -1,4 +1,5 @@
 pub mod audio;
+pub mod bluetooth;
 pub mod default_apps;
 pub mod network;
 pub mod storage;
@@ -14,9 +15,10 @@ pub mod notifications;
 pub enum Page {
     Accounts,
     Audio,
+    Bluetooth,
     DefaultApps,
+    Network,
     Notifications,
-    Radios,
     Storage,
     System,
     Processes,
@@ -26,15 +28,16 @@ pub enum Page {
 }
 
 impl Page {
-    pub const ALL: [Page; 11] = [
+    pub const ALL: [Page; 12] = [
         Page::Accounts,
         Page::Audio,
+        Page::Bluetooth,
         Page::DefaultApps,
         Page::Fonts,
+        Page::Network,
         Page::Notifications,
         Page::Packages,
         Page::Processes,
-        Page::Radios,
         Page::Services,
         Page::Storage,
         Page::System,
@@ -44,9 +47,10 @@ impl Page {
         match self {
             Page::Accounts => "Accounts",
             Page::Audio => "Audio",
+            Page::Bluetooth => "Bluetooth",
             Page::DefaultApps => "Default Apps",
+            Page::Network => "Network",
             Page::Notifications => "Notifications",
-            Page::Radios => "Radios",
             Page::Storage => "Storage",
             Page::System => "System",
             Page::Processes => "Processes",
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 89ff7b1..94db000 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,4 +1,4 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent};
 use crate::scroll_region::ScrollRegion;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
 use cce_ui::widget::{Adapted, Toggle};
@@ -11,14 +11,6 @@ pub struct WifiNetwork {
     pub in_use: bool,
 }
 
-#[derive(Debug, Clone)]
-pub struct BluetoothDevice {
-    pub mac: String,
-    pub name: String,
-    pub icon: String,
-    pub connected: bool,
-}
-
 #[derive(Debug, Clone)]
 pub struct NetworkState {
     pub loaded: bool,
@@ -28,14 +20,8 @@ pub struct NetworkState {
     pub ip_address: String,
     pub device: String,
     pub available: Vec<WifiNetwork>,
-    pub bt_installed: bool,
-    pub bt_service_active: bool,
-    pub bt_enabled: bool,
-    pub bt_devices: Vec<BluetoothDevice>,
-    pub bt_scanning: bool,
     pub wifi_list: ScrollRegion,
     pub wifi_toggle: Adapted<Toggle>,
-    pub bt_toggle: Adapted<Toggle>,
 }
 
 impl Default for NetworkState {
@@ -48,14 +34,8 @@ impl Default for NetworkState {
             ip_address: String::new(),
             device: String::new(),
             available: Vec::new(),
-            bt_installed: false,
-            bt_service_active: false,
-            bt_enabled: false,
-            bt_devices: Vec::new(),
-            bt_scanning: false,
             wifi_list: ScrollRegion::new(26.0, 4.0),
             wifi_toggle: Toggle::new(),
-            bt_toggle: Toggle::new(),
         }
     }
 }
@@ -65,12 +45,6 @@ pub enum NetworkMessage {
     Refreshed(NetworkState),
     ToggleWifi,
     ConnectWifi(String),
-    ToggleBluetooth,
-    BtConnect(String),
-    BtDisconnect(String),
-    BtScan,
-    InstallBtTools,
-    StartBtService,
 }
 
 pub async fn fetch_network_state() -> NetworkState {
@@ -119,17 +93,13 @@ pub async fn fetch_network_state() -> NetworkState {
         }).unwrap_or_default();
 
     let available = if wifi_enabled { fetch_wifi_list().await } else { Vec::new() };
-    let (bt_installed, bt_service_active, bt_enabled, bt_devices) = fetch_bluetooth_state().await;
 
     NetworkState {
         loaded: true,
         wifi_enabled, connected_ssid, signal_strength: signal,
         ip_address, device, available,
-        bt_installed, bt_service_active,
-        bt_enabled, bt_devices, bt_scanning: false,
         wifi_list: ScrollRegion::new(26.0, 4.0),
         wifi_toggle: Toggle::new(),
-        bt_toggle: Toggle::new(),
     }
 }
 
@@ -161,79 +131,6 @@ async fn fetch_wifi_list() -> Vec<WifiNetwork> {
     networks
 }
 
-async fn fetch_bluetooth_state() -> (bool, bool, bool, Vec<BluetoothDevice>) {
-    let bt_installed = tokio::process::Command::new("bluetoothctl")
-        .arg("--version")
-        .output()
-        .await
-        .is_ok();
-
-    if !bt_installed {
-        return (false, false, false, Vec::new());
-    }
-
-    let bt_service_active = tokio::process::Command::new("systemctl")
-        .args(["is-active", "bluetooth"])
-        .output()
-        .await
-        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
-        .unwrap_or(false);
-
-    if !bt_service_active {
-        return (true, false, false, Vec::new());
-    }
-
-    let bt_enabled = tokio::process::Command::new("bluetoothctl")
-        .args(["show"]).output().await.ok()
-        .map(|o| String::from_utf8_lossy(&o.stdout).lines().any(|l| l.contains("Powered: yes")))
-        .unwrap_or(false);
-
-    let devices = if bt_enabled { fetch_bt_devices().await } else { Vec::new() };
-    (true, true, bt_enabled, devices)
-}
-
-async fn fetch_bt_devices() -> Vec<BluetoothDevice> {
-    let out = match tokio::process::Command::new("bluetoothctl")
-        .args(["devices"]).output().await
-    {
-        Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
-        Err(_) => return Vec::new(),
-    };
-
-    let mut devices = Vec::new();
-    for line in out.lines() {
-        let rest = line.strip_prefix("Device ").unwrap_or("");
-        let parts: Vec<&str> = rest.splitn(2, ' ').collect();
-        if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() { continue; }
-        let mac = parts[0].to_string();
-        let default_name = parts[1].to_string();
-
-        let info = tokio::process::Command::new("bluetoothctl")
-            .args(["info", &mac]).output().await.ok()
-            .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
-            .unwrap_or_default();
-
-        let info_name = info.lines()
-            .find(|l| l.contains("Name:"))
-            .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
-
-        let info_alias = info.lines()
-            .find(|l| l.contains("Alias:"))
-            .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
-
-        let name = info_name.or(info_alias).unwrap_or(default_name);
-
-        let connected = info.lines().any(|l| l.contains("Connected: yes"));
-        let icon = info.lines()
-            .find(|l| l.contains("Icon:"))
-            .and_then(|l| l.split(':').nth(1).map(|s| s.trim().to_string()))
-            .unwrap_or_else(|| "audio-card".into());
-
-        devices.push(BluetoothDevice { mac, name, icon, connected });
-    }
-    devices
-}
-
 fn wifi_connect(ssid: &str) {
     let _ = tokio::process::Command::new("nmcli")
         .args(["dev", "wifi", "connect", ssid]).spawn();
@@ -244,45 +141,20 @@ fn wifi_toggle(enable: bool) {
         .args(["radio", "wifi", if enable { "on" } else { "off" }]).spawn();
 }
 
-fn bt_toggle(enable: bool) {
-    let _ = tokio::process::Command::new("bluetoothctl")
-        .args([if enable { "power" } else { "power" }, if enable { "on" } else { "off" }]).spawn();
-}
-
-fn bt_connect(mac: &str) {
-    let _ = tokio::process::Command::new("bluetoothctl")
-        .args(["connect", mac]).spawn();
-}
-
-fn bt_disconnect(mac: &str) {
-    let _ = tokio::process::Command::new("bluetoothctl")
-        .args(["disconnect", mac]).spawn();
-}
-
-fn bt_scan() {
-    let _ = tokio::process::Command::new("bluetoothctl")
-        .args(["scan", "on"]).spawn();
-    let _ = tokio::process::Command::new("sh")
-        .args(["-c", "sleep 5 && bluetoothctl scan off"]).spawn();
-}
-
 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
-const TOGGLE_ON: [f32; 4] = [0.16, 0.41, 0.18, 1.0];
-const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
 const NET_BTN: [f32; 4] = [0.13, 0.20, 0.27, 1.0];
 const ACT_BTN: [f32; 4] = [0.16, 0.29, 0.18, 1.0];
-const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 
 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 {
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
-    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(2);
+    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
-    // ── WiFi ──
-    builder.add_section(&mut final_pc, "WiFi", root_focused, |sec| {
+    // ── WiFi (label-less well) ──
+    builder.add_section_spanned(&mut final_pc, "", 1, root_focused, |sec| {
         let sec_w = sec.cw;
         let rx = sec.left;
         let padding = sec.padding();
@@ -293,42 +165,37 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
             sec.text("Loading WiFi interfaces...", margin, 0.0, 12.0, TEXT_DIM);
         } else {
             let wifi_btn_w = if sec_w < 200.0 { 40.0 } else { 60.0 };
-            let wifi_btn_x = margin;
 
             state.wifi_toggle.set_toggled(state.wifi_enabled);
             state.wifi_toggle.set_label(if state.wifi_enabled { "ON" } else { "OFF" });
-            sec.widget(&mut state.wifi_toggle, wifi_btn_x, wifi_btn_w, 28.0, ctx);
+            // Hand-placed at its real width — sec.widget grid-places at full
+            // column width (the old wide "ON" plate).
+            let yt = sec.ay();
+            let tx = sec.ax(margin);
+            cce_ui::layout::render_widget(sec.pc, &mut state.wifi_toggle, tx, yt, wifi_btn_w, 28.0, ctx);
+            sec.content_y = yt + 28.0 + row_gap;
 
             if state.wifi_enabled {
-                let status_y = sec.ay();
-                let font_size_1 = 13.0;
-                let font_size_2 = 12.0;
-                let status_area_h;
-
+                // Flowing text rows — sec.text advances content_y itself.
                 if !state.connected_ssid.is_empty() {
-                    let y1 = 0.0;
-                    let y2 = font_size_1 + row_gap;
-                    status_area_h = y2 + font_size_2 + row_gap;
-
                     let ssid_max_chars = ((sec_w - 2.0 * margin) / 7.0) as usize;
                     let ssid_truncated = if state.connected_ssid.len() > ssid_max_chars {
                         format!("{}...", &state.connected_ssid[..ssid_max_chars.saturating_sub(3)])
                     } else {
                         state.connected_ssid.clone()
                     };
-                    sec.text(&format!("Connected: {}", ssid_truncated), margin, y1, font_size_1, ACCENT);
+                    sec.text(&format!("Connected: {}", ssid_truncated), margin, 0.0, 13.0, ACCENT);
 
                     if sec_w < 220.0 {
-                        sec.text(&format!("Signal: {}%", state.signal_strength), margin, y2, font_size_2, TEXT_DIM);
+                        sec.text(&format!("Signal: {}%", state.signal_strength), margin, 0.0, 12.0, TEXT_DIM);
                     } else {
                         sec.text(&format!("Signal: {}%  IP: {}", state.signal_strength, state.ip_address),
-                            margin, y2, font_size_2, TEXT_DIM);
+                            margin, 0.0, 12.0, TEXT_DIM);
                     }
                 } else {
-                    status_area_h = font_size_2 + row_gap;
-                    sec.text("Not connected", margin, 0.0, font_size_2, TEXT_DIM);
+                    sec.text("Not connected", margin, 0.0, 12.0, TEXT_DIM);
                 }
-                sec.content_y = status_y + status_area_h;
+                sec.content_y += row_gap;
             }
 
             if state.wifi_enabled && !state.available.is_empty() {
@@ -359,7 +226,7 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
                         sec.pc.button(&label, list_box_x + margin, draw_y, btn_w, 26.0,
                             if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
                             if active { ACCENT } else { TEXT_FG },
-                            AppAction::Radios(NetworkMessage::ConnectWifi(net.ssid.clone())));
+                            AppAction::Network(NetworkMessage::ConnectWifi(net.ssid.clone())));
                     }
                 }
                 sec.pc.pop_clip_rect();
@@ -368,110 +235,6 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
         }
     });
 
-    // ── Bluetooth ──
-    builder.add_section(&mut final_pc, "Bluetooth", false, |sec| {
-        let bt_sec_w = sec.cw;
-        let padding = sec.padding();
-        let row_gap = cce_ui::layout::label_margin();
-        let margin = padding.max(12.0);
-        let font_size = 12.0;
-        let btn_h = 28.0;
-
-        if !state.loaded {
-            sec.text("Loading Bluetooth status...", margin, 0.0, font_size, TEXT_DIM);
-        } else if !state.bt_installed {
-            sec.text("Bluetooth tools (bluez) not installed", margin, 0.0, font_size, TEXT_DIM);
-            let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
-            let yt = sec.ay();
-            sec.button("Install Tools", sec.ax(margin), yt, btn_w, btn_h,
-                TOGGLE_ON, BTN_HOVER, WHITE,
-                AppAction::Radios(NetworkMessage::InstallBtTools));
-            sec.content_y = yt + btn_h + row_gap;
-        } else if !state.bt_service_active {
-            sec.text("Bluetooth service is stopped", margin, 0.0, font_size, TEXT_DIM);
-            let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
-            let yt = sec.ay();
-            sec.button("Start Service", sec.ax(margin), yt, btn_w, btn_h,
-                TOGGLE_ON, BTN_HOVER, WHITE,
-                AppAction::Radios(NetworkMessage::StartBtService));
-            sec.content_y = yt + btn_h + row_gap;
-        } else {
-            let yt = sec.ay();
-            let bt_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 60.0 };
-            let scan_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 52.0 };
-            let bt_btn_x = margin;
-            let scan_btn_x = margin + bt_btn_w + row_gap;
- 
-            state.bt_toggle.set_toggled(state.bt_enabled);
-            state.bt_toggle.set_label(if state.bt_enabled { "ON" } else { "OFF" });
-            sec.widget(&mut state.bt_toggle, bt_btn_x, bt_btn_w, btn_h, ctx);
-            sec.button("Scan", sec.ax(scan_btn_x), yt, scan_btn_w, btn_h,
-                TOGGLE_OFF, BTN_HOVER, WHITE,
-                AppAction::Radios(NetworkMessage::BtScan));
-            sec.content_y = yt + btn_h + row_gap;
- 
-            if state.bt_devices.is_empty() {
-                if state.bt_enabled {
-                    let no_devices_msg = if bt_sec_w < 200.0 { "No paired devices" } else { "No paired devices found" };
-                    sec.text(no_devices_msg, margin, 0.0, font_size, TEXT_DIM);
-                }
-            } else {
-                let item_h = 22.0;
-                for dev in &state.bt_devices {
-                    let status = if dev.connected { ">" } else { " " };
-                    let btn_w = if bt_sec_w < 250.0 { 42.0 } else { 70.0 };
-                    let action_label = if dev.connected {
-                        if bt_sec_w < 250.0 { "Disc" } else { "Disconnect" }
-                    } else {
-                        if bt_sec_w < 250.0 { "Conn" } else { "Connect" }
-                    };
- 
-                    let label_max_w = (bt_sec_w - btn_w - 2.0 * padding - margin - row_gap).max(20.0);
-                    let label_max_chars = ((label_max_w / 6.0) as usize).max(5);
- 
-                    let is_unknown = dev.name.replace('-', ":").eq_ignore_ascii_case(&dev.mac);
-                    let label = if is_unknown {
-                        if bt_sec_w < 350.0 {
-                            format!("{} {}", status, dev.mac)
-                        } else {
-                            format!("{} Unknown Device ({})", status, dev.mac)
-                        }
-                    } else {
-                        if bt_sec_w < 350.0 {
-                            let name_truncated = if dev.name.len() > label_max_chars {
-                                format!("{}...", &dev.name[..label_max_chars.saturating_sub(3)])
-                            } else {
-                                dev.name.clone()
-                            };
-                            format!("{} {}", status, name_truncated)
-                        } else {
-                            let full_label = format!("{} {} ({})", status, dev.name, dev.mac);
-                            if full_label.len() > label_max_chars {
-                                format!("{}...", &full_label[..label_max_chars.saturating_sub(3)])
-                            } else {
-                                full_label
-                            }
-                        }
-                    };
- 
-                    let yt = sec.ay();
-                    let btn_x = margin;
-                    let text_x = margin + btn_w + row_gap;
-                    let text_y_offset = (item_h - font_size) / 2.0;
-                    sec.button(action_label, sec.ax(btn_x), yt, btn_w, item_h,
-                        if dev.connected { TOGGLE_OFF } else { TOGGLE_ON }, BTN_HOVER, WHITE,
-                        if dev.connected {
-                            AppAction::Radios(NetworkMessage::BtDisconnect(dev.mac.clone()))
-                        } else {
-                            AppAction::Radios(NetworkMessage::BtConnect(dev.mac.clone()))
-                        });
-                    sec.text(&label, text_x, text_y_offset, font_size, if dev.connected { ACCENT } else { TEXT_FG });
-                    sec.content_y = yt + item_h + row_gap;
-                }
-            }
-        }
-    });
-
     final_pc
 }
 
@@ -485,34 +248,12 @@ pub fn update(state: &mut NetworkState, msg: NetworkMessage) {
             state.ip_address = new.ip_address;
             state.device = new.device;
             state.available = new.available;
-            state.bt_installed = new.bt_installed;
-            state.bt_service_active = new.bt_service_active;
-            state.bt_enabled = new.bt_enabled;
-            state.bt_devices = new.bt_devices;
-            state.bt_scanning = new.bt_scanning;
         }
         NetworkMessage::ToggleWifi => {
             state.wifi_enabled = !state.wifi_enabled;
             wifi_toggle(state.wifi_enabled);
         }
         NetworkMessage::ConnectWifi(ssid) => { wifi_connect(&ssid); }
-        NetworkMessage::ToggleBluetooth => {
-            state.bt_enabled = !state.bt_enabled;
-            bt_toggle(state.bt_enabled);
-        }
-        NetworkMessage::BtConnect(mac) => { bt_connect(&mac); }
-        NetworkMessage::BtDisconnect(mac) => { bt_disconnect(&mac); }
-        NetworkMessage::BtScan => { bt_scan(); }
-        NetworkMessage::InstallBtTools => {
-            let _ = tokio::process::Command::new("pkexec")
-                .args(["sh", "-c", "pacman -S --noconfirm bluez bluez-utils && systemctl enable --now bluetooth"])
-                .spawn();
-        }
-        NetworkMessage::StartBtService => {
-            let _ = tokio::process::Command::new("pkexec")
-                .args(["systemctl", "enable", "--now", "bluetooth"])
-                .spawn();
-        }
     }
 }
 
@@ -525,12 +266,9 @@ impl NetworkState {
 }
 
 impl crate::pages::AppPage for NetworkState {
-    // Sections: [WiFi, Bluetooth]
+    // Sections: [WiFi]
     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
-        vec![
-            vec![self.wifi_toggle.id()],
-            vec![self.bt_toggle.id()],
-        ]
+        vec![vec![self.wifi_toggle.id()]]
     }
 
     fn view(
@@ -552,10 +290,7 @@ impl crate::pages::AppPage for NetworkState {
 
     fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
         if self.wifi_toggle.take_change() {
-            actions.push(crate::app::AppAction::Radios(NetworkMessage::ToggleWifi));
-        }
-        if self.bt_toggle.take_change() {
-            actions.push(crate::app::AppAction::Radios(NetworkMessage::ToggleBluetooth));
+            actions.push(crate::app::AppAction::Network(NetworkMessage::ToggleWifi));
         }
     }
 
diff --git a/src/watchers.rs b/src/watchers.rs
index 32b0638..20112ad 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -1,11 +1,12 @@
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU8, Ordering};
 use std::sync::mpsc::{channel, Receiver, Sender};
-use crate::pages::{Page, audio, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications};
+use crate::pages::{Page, audio, bluetooth, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications};
 
 pub struct Watchers {
     pub rx_audio: Receiver<audio::AudioState>,
     pub rx_network: Receiver<network::NetworkState>,
+    pub rx_bluetooth: Receiver<bluetooth::BluetoothState>,
     pub rx_processes: Receiver<processes::ProcessesState>,
     pub rx_system: Receiver<system_info::SystemInfo>,
     pub rx_storage: Receiver<storage::StorageState>,
@@ -59,7 +60,8 @@ pub fn spawn_all(
     Receiver<packages::PackagesMessage>,
 ) {
     let rx_audio = spawn_bg_active(current_page_shared.clone(), Page::Audio.index() as u8, 3, || audio::fetch_audio_state());
-    let rx_network = spawn_bg_active(current_page_shared.clone(), Page::Radios.index() as u8, 5, || network::fetch_network_state());
+    let rx_network = spawn_bg_active(current_page_shared.clone(), Page::Network.index() as u8, 5, || network::fetch_network_state());
+    let rx_bluetooth = spawn_bg_active(current_page_shared.clone(), Page::Bluetooth.index() as u8, 5, || bluetooth::fetch_bluetooth_page_state());
 
     let rx_system = spawn_bg_active(current_page_shared.clone(), Page::System.index() as u8, 5, || system_info::fetch_system_state());
     let rx_processes = spawn_bg_active(current_page_shared.clone(), Page::Processes.index() as u8, 3, || processes::fetch_processes_state());
@@ -104,6 +106,7 @@ pub fn spawn_all(
         Watchers {
             rx_audio,
             rx_network,
+            rx_bluetooth,
             rx_processes,
             rx_system,
             rx_storage,